fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案
This commit is contained in:
1 parent
6997a65c96
commit
d364c6d908
46 files changed
+2060
-395
No files matched your search
@@ -1,20 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
String? kratosReason(Object e) {
|
||||
if (e is! DioException) return null;
|
||||
final body = e.response?.data;
|
||||
if (body is Map && body['reason'] is String) return body['reason'] as String;
|
||||
if (e is DioException || e is ApiException || e is FormatException) {
|
||||
return ApiException.from(e).reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String kratosDisplayMessage(Object e, {required String fallback}) {
|
||||
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
|
||||
if (e is DioException) {
|
||||
final body = e.response?.data;
|
||||
if (body is Map && body['message'] is String) {
|
||||
final m = body['message'] as String;
|
||||
if (m.isNotEmpty) return m;
|
||||
}
|
||||
final exception = ApiException.from(e);
|
||||
if (exception.serverMessage case final message? when message.isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
abstract final class ErrorMessageMapper {
|
||||
static String message(Object error, {required String fallback}) =>
|
||||
kratosDisplayMessage(error, fallback: fallback);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/pb/errors/errors.pb.dart' as kerrors;
|
||||
|
||||
enum ApiExceptionKind { transport, http, backend, decode, cancelled }
|
||||
|
||||
/// The single network failure shape exposed to application code.
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.kind,
|
||||
this.httpStatus,
|
||||
this.backendCode,
|
||||
this.reason,
|
||||
this.serverMessage,
|
||||
this.requestOptions,
|
||||
this.cause,
|
||||
});
|
||||
|
||||
final ApiExceptionKind kind;
|
||||
final int? httpStatus;
|
||||
final int? backendCode;
|
||||
final String? reason;
|
||||
final String? serverMessage;
|
||||
final RequestOptions? requestOptions;
|
||||
final Object? cause;
|
||||
|
||||
bool get isUnauthorized =>
|
||||
httpStatus == 401 ||
|
||||
const {'INVALID_TOKEN', 'TOKEN_EXPIRED'}.contains(reason?.toUpperCase());
|
||||
|
||||
bool get isRetryable {
|
||||
if (kind == ApiExceptionKind.transport) return true;
|
||||
final status = httpStatus;
|
||||
return status == 408 || status == 429 || (status != null && status >= 500);
|
||||
}
|
||||
|
||||
int? get sessionGeneration =>
|
||||
requestOptions?.extra[authSessionGenerationExtra] as int?;
|
||||
|
||||
static ApiException from(Object error, {StackTrace? stackTrace}) {
|
||||
if (error is ApiException) return error;
|
||||
if (error is DioException) {
|
||||
if (error.error is ApiException) return error.error! as ApiException;
|
||||
return fromDio(error);
|
||||
}
|
||||
if (error is FormatException) {
|
||||
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||
}
|
||||
return ApiException(kind: ApiExceptionKind.decode, cause: error);
|
||||
}
|
||||
|
||||
static ApiException fromDio(DioException error) {
|
||||
final response = error.response;
|
||||
final status = response?.statusCode;
|
||||
final kratos = parseKratosError(response?.data);
|
||||
if (error.type == DioExceptionType.cancel) {
|
||||
return ApiException(
|
||||
kind: ApiExceptionKind.cancelled,
|
||||
httpStatus: status,
|
||||
requestOptions: error.requestOptions,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
final kind = kratos != null
|
||||
? ApiExceptionKind.backend
|
||||
: status != null
|
||||
? ApiExceptionKind.http
|
||||
: ApiExceptionKind.transport;
|
||||
return ApiException(
|
||||
kind: kind,
|
||||
httpStatus: status,
|
||||
backendCode: kratos?.code,
|
||||
reason: _normalized(kratos?.reason),
|
||||
serverMessage: _normalized(kratos?.message),
|
||||
requestOptions: error.requestOptions,
|
||||
cause: error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApiException($kind, status: $httpStatus, code: $backendCode, '
|
||||
'reason: $reason, message: $serverMessage)';
|
||||
}
|
||||
|
||||
const authSessionGenerationExtra = 'authSessionGeneration';
|
||||
|
||||
kerrors.Status? parseKratosError(Object? raw) {
|
||||
if (raw is List<int> && raw.isNotEmpty) {
|
||||
try {
|
||||
final status = kerrors.Status.fromBuffer(raw);
|
||||
if (status.code != 0 || status.reason.isNotEmpty) return status;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (raw is Map) {
|
||||
final code = raw['code'];
|
||||
final reason = '${raw['reason'] ?? ''}';
|
||||
final message = '${raw['message'] ?? ''}';
|
||||
if (code is num || reason.isNotEmpty || message.isNotEmpty) {
|
||||
return kerrors.Status(
|
||||
code: code is num ? code.toInt() : 0,
|
||||
reason: reason,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _normalized(String? value) {
|
||||
final result = value?.trim() ?? '';
|
||||
return result.isEmpty ? null : result;
|
||||
}
|
||||
|
||||
class ApiExceptionInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final apiException = ApiException.fromDio(err);
|
||||
handler.next(err.copyWith(error: apiException));
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../services/device_header_service.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
final _defaultIdentityStorage = SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
PreferencesStorage(StorageService.to.preferences),
|
||||
);
|
||||
|
||||
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
||||
@@ -13,22 +15,51 @@ Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
|
||||
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
|
||||
|
||||
class AuthInterceptor extends Interceptor {
|
||||
AuthInterceptor(this._identityStorage);
|
||||
AuthInterceptor(this._sessionStorage, {required Uri backendOrigin})
|
||||
: _backendOrigin = backendOrigin;
|
||||
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
final AuthSessionStorage _sessionStorage;
|
||||
final Uri _backendOrigin;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
if (!_sameOrigin(options.uri, _backendOrigin)) {
|
||||
_stripSensitiveHeaders(options.headers);
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
|
||||
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
|
||||
final jwt = await _identityStorage.readJwt();
|
||||
final jwt = _sessionStorage.accessToken ?? await _sessionStorage.restore();
|
||||
if (jwt != null && jwt.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $jwt';
|
||||
}
|
||||
options.extra[authSessionGenerationExtra] = _sessionStorage.generation;
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
bool _sameOrigin(Uri request, Uri backend) =>
|
||||
request.scheme.toLowerCase() == backend.scheme.toLowerCase() &&
|
||||
request.host.toLowerCase() == backend.host.toLowerCase() &&
|
||||
request.port == backend.port;
|
||||
|
||||
void _stripSensitiveHeaders(Map<String, dynamic> headers) {
|
||||
const sensitive = {
|
||||
'authorization',
|
||||
'x-device-id',
|
||||
'user-devicetoken',
|
||||
'user-android-id',
|
||||
'user-android-oaid',
|
||||
'user-android-imei',
|
||||
'user-android-mac',
|
||||
'user-ios-idfv',
|
||||
'user-ios-idfa',
|
||||
'user-ios-paid',
|
||||
};
|
||||
headers.removeWhere((key, _) => sensitive.contains(key.toLowerCase()));
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../../features/auth/application/auth_session_coordinator.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'auth_interceptor.dart';
|
||||
import 'kratos_error_interceptor.dart';
|
||||
|
||||
@@ -26,7 +28,9 @@ AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio dio(Ref ref) {
|
||||
final config = ref.watch(appConfigProvider);
|
||||
final identityStorage = ref.watch(secureIdentityStorageProvider);
|
||||
final sessionStorage = ref.watch(authSessionStorageProvider);
|
||||
final coordinator = ref.watch(authSessionCoordinatorProvider);
|
||||
final backendOrigin = Uri.parse(config.apiBaseUrl);
|
||||
final d = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: config.apiBaseUrl,
|
||||
@@ -40,11 +44,57 @@ Dio dio(Ref ref) {
|
||||
},
|
||||
),
|
||||
);
|
||||
d.interceptors.add(AuthInterceptor(identityStorage));
|
||||
d.interceptors.add(
|
||||
AuthInterceptor(sessionStorage, backendOrigin: backendOrigin),
|
||||
);
|
||||
d.interceptors.add(KratosErrorInterceptor());
|
||||
d.interceptors.add(_SessionExpiryInterceptor(coordinator));
|
||||
if (!kReleaseMode) {
|
||||
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
||||
d.interceptors.add(FancyDioInterceptor());
|
||||
}
|
||||
d.interceptors.add(FancyDioInterceptor());
|
||||
return d;
|
||||
}
|
||||
|
||||
/// Explicit name for new code. [dioProvider] remains as a compatibility alias
|
||||
/// while existing feature clients are migrated incrementally.
|
||||
final backendDioProvider = dioProvider;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Dio publicDio(Ref ref) => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
class _SessionExpiryInterceptor extends Interceptor {
|
||||
_SessionExpiryInterceptor(this._coordinator);
|
||||
|
||||
final AuthSessionCoordinator _coordinator;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final exception = ApiException.from(err);
|
||||
if (exception.isUnauthorized) {
|
||||
// Network code only emits the session event. Navigation and presentation
|
||||
// observe AuthState elsewhere.
|
||||
try {
|
||||
await _coordinator.expireIfNeeded(exception);
|
||||
} catch (error, stackTrace) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(
|
||||
exception: error,
|
||||
stack: stackTrace,
|
||||
library: 'jinzhi network',
|
||||
context: ErrorDescription('while expiring an unauthorized session'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,16 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/pb/errors/errors.pb.dart' as kerrors;
|
||||
import 'api_exception.dart';
|
||||
|
||||
class KratosErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final response = err.response;
|
||||
final raw = response?.data;
|
||||
if (response != null && raw is List<int> && raw.isNotEmpty) {
|
||||
try {
|
||||
final status = kerrors.Status.fromBuffer(raw);
|
||||
if (status.code != 0 || status.reason.isNotEmpty) {
|
||||
response.data = <String, dynamic>{
|
||||
'code': status.code,
|
||||
'reason': status.reason,
|
||||
'message': status.message,
|
||||
'metadata': status.metadata,
|
||||
};
|
||||
}
|
||||
} catch (_) {
|
||||
// A non-Kratos error body keeps its original representation.
|
||||
}
|
||||
if (err.error is ApiException) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
handler.next(err);
|
||||
// Preserve response.data for diagnostics and retries. The parsed error is
|
||||
// carried as DioException.error and normalized by ApiException.from().
|
||||
handler.next(err.copyWith(error: ApiException.fromDio(err)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Minimal reader retained only for endpoints that do not have an
|
||||
/// authoritative generated protobuf model yet.
|
||||
class ProtobufWireMessage {
|
||||
ProtobufWireMessage(this.bytes);
|
||||
|
||||
final List<int> bytes;
|
||||
Map<int, List<Object>>? _cache;
|
||||
|
||||
Map<int, List<Object>> get _fields => _cache ??= _read();
|
||||
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
|
||||
|
||||
String string(int tag) {
|
||||
final value = _fields[tag]?.lastOrNull;
|
||||
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
|
||||
}
|
||||
|
||||
Map<int, List<Object>> _read() {
|
||||
final result = <int, List<Object>>{};
|
||||
var index = 0;
|
||||
|
||||
int varint() {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < bytes.length) {
|
||||
final byte = bytes[index++];
|
||||
value |= (byte & 127) << shift;
|
||||
if (byte < 128) return value;
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
|
||||
while (index < bytes.length) {
|
||||
final key = varint();
|
||||
final tag = key >> 3;
|
||||
final Object field;
|
||||
if ((key & 7) == 0) {
|
||||
field = varint();
|
||||
} else if ((key & 7) == 2) {
|
||||
final length = varint();
|
||||
final end = index + length;
|
||||
if (end > bytes.length) throw const FormatException('Invalid protobuf');
|
||||
field = bytes.sublist(index, end);
|
||||
index = end;
|
||||
} else {
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
(result[tag] ??= []).add(field);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<Object> {
|
||||
Object? get lastOrNull => isEmpty ? null : last;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../network/dio_provider.dart';
|
||||
import '../../models/pb/jinzhi/v1/push.pb.dart' as pb;
|
||||
import '../network/api_exception.dart';
|
||||
import 'notification_permission.dart';
|
||||
import 'push_api_client.dart';
|
||||
import 'umeng_push_service.dart';
|
||||
|
||||
class DevicePushRegistrar {
|
||||
@@ -15,81 +15,52 @@ class DevicePushRegistrar {
|
||||
StreamSubscription<String>? _subscription;
|
||||
String? _lastToken;
|
||||
bool? _lastPermission;
|
||||
int _attempt = 0;
|
||||
Future<void> _registrationQueue = Future.value();
|
||||
|
||||
void start() {
|
||||
_subscription ??= UmengPushService.onDeviceToken.listen(_register);
|
||||
_subscription ??= UmengPushService.onDeviceToken.listen(_enqueue);
|
||||
UmengPushService.emitCachedToken();
|
||||
}
|
||||
|
||||
Future<void> stop() async => _subscription?.cancel();
|
||||
Future<void> refresh() => UmengPushService.refreshDeviceToken();
|
||||
void forceRegisterCurrentToken() {
|
||||
// BLOCKED(server contract): guest registration is intentionally preserved.
|
||||
// Do not add a client-side unbind call until the backend confirms whether
|
||||
// guest registration reliably clears the previous user binding.
|
||||
_lastToken = null;
|
||||
_lastPermission = null;
|
||||
UmengPushService.emitCachedToken();
|
||||
}
|
||||
|
||||
void _enqueue(String token) {
|
||||
_registrationQueue = _registrationQueue
|
||||
.catchError((_) {})
|
||||
.then((_) => _register(token));
|
||||
}
|
||||
|
||||
Future<void> _register(String token) async {
|
||||
final permission = await NotificationPermission.isGranted();
|
||||
if (token == _lastToken && permission == _lastPermission) return;
|
||||
try {
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
await _read(dioProvider).post<List<int>>(
|
||||
'/api/v1/push/devices',
|
||||
data: _PushRegisterRequest(
|
||||
token: token,
|
||||
platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2,
|
||||
version: package.version,
|
||||
permission: permission,
|
||||
).toBytes(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
_lastToken = token;
|
||||
_lastPermission = permission;
|
||||
_attempt = 0;
|
||||
} catch (_) {
|
||||
if (_attempt++ >= 3) return;
|
||||
await Future<void>.delayed(Duration(seconds: 1 << (_attempt - 1)));
|
||||
await _register(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PushRegisterRequest {
|
||||
const _PushRegisterRequest({
|
||||
required this.token,
|
||||
required this.platform,
|
||||
required this.version,
|
||||
required this.permission,
|
||||
});
|
||||
final String token;
|
||||
final int platform;
|
||||
final String version;
|
||||
final bool permission;
|
||||
Uint8List toBytes() {
|
||||
final bytes = BytesBuilder(copy: false);
|
||||
void putInt(int value) {
|
||||
while (value > 0x7f) {
|
||||
bytes.addByte((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
final request = pb.RegisterPushDeviceRequest(
|
||||
umengDeviceToken: token,
|
||||
platform: defaultTargetPlatform == TargetPlatform.iOS
|
||||
? pb.Platform.PLATFORM_IOS
|
||||
: pb.Platform.PLATFORM_ANDROID,
|
||||
appVersion: package.version,
|
||||
permissionGranted: permission,
|
||||
);
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
await _read(pushApiClientProvider).registerDevice(request);
|
||||
_lastToken = token;
|
||||
_lastPermission = permission;
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt == 3 || !ApiException.from(error).isRetryable) return;
|
||||
await Future<void>.delayed(Duration(seconds: 1 << attempt));
|
||||
}
|
||||
bytes.addByte(value);
|
||||
}
|
||||
|
||||
void putString(int tag, String value) {
|
||||
final raw = value.codeUnits;
|
||||
putInt((tag << 3) | 2);
|
||||
putInt(raw.length);
|
||||
bytes.add(raw);
|
||||
}
|
||||
|
||||
putString(1, token);
|
||||
putInt(2 << 3);
|
||||
putInt(platform);
|
||||
putString(3, version);
|
||||
putInt(4 << 3);
|
||||
putInt(permission ? 1 : 0);
|
||||
return bytes.toBytes();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
// ignore: unused_import — compute() is referenced by generated Retrofit code.
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
import '../../models/pb/jinzhi/v1/push.pb.dart';
|
||||
import '../network/dio_provider.dart';
|
||||
|
||||
part 'push_api_client.g.dart';
|
||||
|
||||
@RestApi()
|
||||
abstract class PushApiClient {
|
||||
factory PushApiClient(Dio dio, {String? baseUrl}) = _PushApiClient;
|
||||
|
||||
@POST('/api/v1/push/devices')
|
||||
Future<RegisterPushDeviceReply> registerDevice(
|
||||
@Body() RegisterPushDeviceRequest request,
|
||||
);
|
||||
}
|
||||
|
||||
final pushApiClientProvider = Provider<PushApiClient>(
|
||||
(ref) => PushApiClient(ref.watch(backendDioProvider)),
|
||||
);
|
||||
@@ -12,11 +12,12 @@ import '../storage/storage_keys.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
abstract final class UmengPushService {
|
||||
static const _maxPendingNotificationOpens = 20;
|
||||
static final _tokens = StreamController<String>.broadcast();
|
||||
static Future<void>? _starting;
|
||||
static bool _started = false;
|
||||
static void Function(String payload)? _onNotificationOpen;
|
||||
static String? _pendingNotificationOpen;
|
||||
static final List<String> _pendingNotificationOpens = [];
|
||||
|
||||
static Stream<String> get onDeviceToken => _tokens.stream;
|
||||
|
||||
@@ -24,10 +25,10 @@ abstract final class UmengPushService {
|
||||
void Function(String payload) callback,
|
||||
) {
|
||||
_onNotificationOpen = callback;
|
||||
final pending = _pendingNotificationOpen;
|
||||
if (pending != null) {
|
||||
_pendingNotificationOpen = null;
|
||||
callback(pending);
|
||||
final pending = List<String>.of(_pendingNotificationOpens);
|
||||
_pendingNotificationOpens.clear();
|
||||
for (final payload in pending) {
|
||||
callback(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,10 @@ abstract final class UmengPushService {
|
||||
UmengPushSdk.setNotificationCallback((_) {}, (payload) {
|
||||
final callback = _onNotificationOpen;
|
||||
if (callback == null) {
|
||||
_pendingNotificationOpen = payload;
|
||||
if (_pendingNotificationOpens.length == _maxPendingNotificationOpens) {
|
||||
_pendingNotificationOpens.removeAt(0);
|
||||
}
|
||||
_pendingNotificationOpens.add(payload);
|
||||
} else {
|
||||
callback(payload);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,177 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
enum DeepLinkSource { push, appLink, universalLink, customScheme }
|
||||
|
||||
import 'app_router.dart';
|
||||
typedef DeepLinkNavigator = bool Function(String location);
|
||||
typedef DeepLinkAuthGuard = bool Function(String location);
|
||||
|
||||
/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。
|
||||
class DeepLinkEvent {
|
||||
const DeepLinkEvent({
|
||||
required this.raw,
|
||||
required this.source,
|
||||
required this.receivedAt,
|
||||
this.eventId,
|
||||
});
|
||||
|
||||
final String raw;
|
||||
final DeepLinkSource source;
|
||||
final DateTime receivedAt;
|
||||
final String? eventId;
|
||||
}
|
||||
|
||||
/// Source adapters feed one parser and a bounded FIFO. Navigation starts only
|
||||
/// after GoRouter has a context and the auth guard accepts the target.
|
||||
abstract final class DeepLinkService {
|
||||
static void handle(String raw) {
|
||||
final target = _route(raw);
|
||||
if (target != null) rootNavigatorKey.currentContext?.go(target);
|
||||
static const maxPending = 20;
|
||||
static const expiry = Duration(minutes: 15);
|
||||
static const _environmentHosts = String.fromEnvironment(
|
||||
'JINZHI_DEEP_LINK_HOSTS',
|
||||
);
|
||||
|
||||
static final List<DeepLinkEvent> _pending = [];
|
||||
static final Set<String> _pendingEventIds = {};
|
||||
static final Map<String, DateTime> _seenEventIds = {};
|
||||
static final Set<String> _configuredHttpsHosts = _normalizedHosts(
|
||||
_environmentHosts.split(','),
|
||||
);
|
||||
static Set<String> _trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||
static DeepLinkNavigator? _navigator;
|
||||
static DeepLinkAuthGuard _authGuard = (_) => true;
|
||||
|
||||
static int get pendingCount => _pending.length;
|
||||
|
||||
static void handle(String raw) => enqueue(raw, source: DeepLinkSource.push);
|
||||
|
||||
static void handleUri(Uri uri) => enqueue(
|
||||
uri.toString(),
|
||||
source: uri.scheme.toLowerCase() == 'jinzhi'
|
||||
? DeepLinkSource.customScheme
|
||||
: DeepLinkSource.universalLink,
|
||||
);
|
||||
|
||||
static void enqueue(
|
||||
String raw, {
|
||||
required DeepLinkSource source,
|
||||
DateTime? receivedAt,
|
||||
}) {
|
||||
final normalized = raw.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
final event = DeepLinkEvent(
|
||||
raw: normalized,
|
||||
source: source,
|
||||
receivedAt: receivedAt ?? DateTime.now(),
|
||||
eventId: _eventId(normalized),
|
||||
);
|
||||
_discardExpired(event.receivedAt);
|
||||
_seenEventIds.removeWhere(
|
||||
(_, seenAt) => event.receivedAt.difference(seenAt) > expiry,
|
||||
);
|
||||
if (event.eventId case final id? when _seenEventIds.containsKey(id)) return;
|
||||
if (_pending.length == maxPending) _removeAt(0);
|
||||
_pending.add(event);
|
||||
if (event.eventId case final id?) {
|
||||
_pendingEventIds.add(id);
|
||||
_seenEventIds[id] = event.receivedAt;
|
||||
}
|
||||
_drain();
|
||||
}
|
||||
|
||||
static void attachRouter(
|
||||
DeepLinkNavigator navigator, {
|
||||
DeepLinkAuthGuard? authGuard,
|
||||
}) {
|
||||
_navigator = navigator;
|
||||
_authGuard = authGuard ?? (_) => true;
|
||||
_drain();
|
||||
}
|
||||
|
||||
static void notifyAuthChanged() => _drain();
|
||||
|
||||
static void detachRouter() => _navigator = null;
|
||||
|
||||
/// Universal/App Link domains are supplied by the release environment. An
|
||||
/// empty allowlist intentionally disables HTTPS deep links until the native
|
||||
/// association domain is confirmed.
|
||||
static void configureTrustedHttpsHosts(Iterable<String> hosts) {
|
||||
_trustedHttpsHosts = _normalizedHosts(hosts);
|
||||
}
|
||||
|
||||
static void resetForTest() {
|
||||
_pending.clear();
|
||||
_pendingEventIds.clear();
|
||||
_seenEventIds.clear();
|
||||
_trustedHttpsHosts = {..._configuredHttpsHosts};
|
||||
_navigator = null;
|
||||
_authGuard = (_) => true;
|
||||
}
|
||||
|
||||
static void _drain() {
|
||||
final navigator = _navigator;
|
||||
if (navigator == null) return;
|
||||
_discardExpired(DateTime.now());
|
||||
while (_pending.isNotEmpty) {
|
||||
final event = _pending.first;
|
||||
final target = _route(event.raw);
|
||||
if (target == null) {
|
||||
_removeAt(0);
|
||||
continue;
|
||||
}
|
||||
if (!_authGuard(target) || !navigator(target)) return;
|
||||
_removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
static void _discardExpired(DateTime now) {
|
||||
while (_pending.isNotEmpty &&
|
||||
now.difference(_pending.first.receivedAt) > expiry) {
|
||||
_removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
static void _removeAt(int index) {
|
||||
final removed = _pending.removeAt(index);
|
||||
if (removed.eventId case final id?) _pendingEventIds.remove(id);
|
||||
}
|
||||
|
||||
static String? _eventId(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map) {
|
||||
final id = '${decoded['event_id'] ?? decoded['eventId'] ?? ''}'.trim();
|
||||
if (id.isNotEmpty) return id;
|
||||
}
|
||||
} catch (_) {}
|
||||
final uri = Uri.tryParse(raw);
|
||||
final id = uri?.queryParameters['event_id']?.trim();
|
||||
return id == null || id.isEmpty ? null : id;
|
||||
}
|
||||
|
||||
static String? _route(String raw) {
|
||||
var source = raw.trim();
|
||||
try {
|
||||
final json = jsonDecode(source);
|
||||
if (json is Map) {
|
||||
source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}';
|
||||
final decoded = jsonDecode(source);
|
||||
if (decoded is Map) {
|
||||
source =
|
||||
'${decoded['deep_link'] ?? decoded['url'] ?? decoded['link'] ?? ''}';
|
||||
}
|
||||
} catch (_) {}
|
||||
final uri = Uri.tryParse(source);
|
||||
if (uri == null) return null;
|
||||
if (uri.scheme == 'jinzhi') {
|
||||
final path = uri.path.isEmpty ? '/assets' : uri.path;
|
||||
if (uri.scheme.toLowerCase() == 'jinzhi') {
|
||||
final path = uri.path.isNotEmpty
|
||||
? uri.path
|
||||
: uri.host.isNotEmpty
|
||||
? '/${uri.host}'
|
||||
: '/assets';
|
||||
return _allowed(path)
|
||||
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
|
||||
: null;
|
||||
}
|
||||
if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) {
|
||||
if (uri.scheme.toLowerCase() == 'https' &&
|
||||
uri.port == 443 &&
|
||||
uri.userInfo.isEmpty &&
|
||||
_trustedHttpsHosts.contains(uri.host.toLowerCase()) &&
|
||||
_allowed(uri.path)) {
|
||||
return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}';
|
||||
}
|
||||
return null;
|
||||
@@ -40,4 +184,9 @@ abstract final class DeepLinkService {
|
||||
'/profile',
|
||||
'/settings',
|
||||
}.contains(path);
|
||||
|
||||
static Set<String> _normalizedHosts(Iterable<String> hosts) => {
|
||||
for (final host in hosts)
|
||||
if (host.trim().isNotEmpty) host.trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import '../../features/web/presentation/mall_web_host.dart';
|
||||
import '../theme/app_icons.dart';
|
||||
import '../widgets/app_toast.dart';
|
||||
import 'app_router.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
|
||||
class TabShell extends ConsumerStatefulWidget {
|
||||
const TabShell({
|
||||
@@ -65,7 +65,9 @@ class _TabShellState extends ConsumerState<TabShell>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mallEntryPromoStore = MallEntryPromoStore(StorageService.to.preferences);
|
||||
_mallEntryPromoStore = MallEntryPromoStore(
|
||||
ref.read(preferencesStorageProvider),
|
||||
);
|
||||
_mallEntryTipConsumed = _mallEntryPromoStore.hasOpenedMall;
|
||||
_mallHomeBadgeSeen = _mallEntryPromoStore.hasSeenHomeBadge;
|
||||
MallWebHostController.instance.state.addListener(_syncMallHost);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../features/web/application/mall_web_host_controller.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
|
||||
/// Clears only disposable presentation data. Credentials and user preferences
|
||||
/// deliberately remain outside this service.
|
||||
@@ -34,7 +35,8 @@ class CacheClearService {
|
||||
/// in SharedPreferences and is removed here without touching user data.
|
||||
abstract final class SplashAdControllerPlaceholder {
|
||||
static Future<void> clear() async {
|
||||
await StorageService.to.preferences.remove('ads.splash.entry');
|
||||
await StorageService.to.preferences.remove('ads.splash.daily');
|
||||
final preferences = PreferencesStorage(StorageService.to.preferences);
|
||||
await preferences.remove('ads.splash.entry');
|
||||
await preferences.remove('ads.splash.daily');
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import 'package:android_oaid/android_oaid.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
|
||||
import 'package:flutter_paid/flutter_paid.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:platform_metadata/platform_metadata.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../storage/storage_keys.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
import '../storage/preferences_storage.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../privacy/privacy_gate.dart';
|
||||
|
||||
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
|
||||
@@ -26,8 +27,6 @@ import '../privacy/privacy_gate.dart';
|
||||
class DeviceHeaderService {
|
||||
DeviceHeaderService._();
|
||||
static final DeviceHeaderService to = DeviceHeaderService._();
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static const _kDeviceIdKey = 'device_id';
|
||||
// static const _platformMetadataChannel = MethodChannel('platform_metadata');
|
||||
|
||||
// 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxx;Android 其次读 manifest,
|
||||
@@ -48,6 +47,9 @@ class DeviceHeaderService {
|
||||
Future<void>? _baseInitialization;
|
||||
Future<void>? _identityInitialization;
|
||||
|
||||
SecureIdentityStorage get _identityStorage =>
|
||||
SecureIdentityStorage(PreferencesStorage(StorageService.to.preferences));
|
||||
|
||||
Future<void> ensureInitialized() async {
|
||||
_baseInitialization ??= _initializeBase();
|
||||
await _baseInitialization;
|
||||
@@ -89,14 +91,14 @@ class DeviceHeaderService {
|
||||
/// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4;
|
||||
/// 首次生成后写入 secure_storage,后续复用。
|
||||
Future<String> _resolveDeviceId() async {
|
||||
final stored = await _storage.read(key: _kDeviceIdKey);
|
||||
final stored = await _identityStorage.readDeviceId();
|
||||
if (stored != null && stored.isNotEmpty) return stored;
|
||||
var id = '';
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
id = await _resolveIdfv();
|
||||
}
|
||||
if (id.isEmpty) id = const Uuid().v4();
|
||||
await _storage.write(key: _kDeviceIdKey, value: id);
|
||||
await _identityStorage.saveDeviceId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'storage_service.dart';
|
||||
|
||||
/// Typed boundary for non-sensitive preferences and display caches.
|
||||
///
|
||||
/// Feature code depends on this class instead of constructing or retaining a
|
||||
/// [SharedPreferences] instance.
|
||||
class PreferencesStorage {
|
||||
const PreferencesStorage(this._preferences);
|
||||
|
||||
final SharedPreferences _preferences;
|
||||
|
||||
bool getBool(String key, {bool defaultValue = false}) =>
|
||||
_preferences.getBool(key) ?? defaultValue;
|
||||
String? getString(String key) => _preferences.getString(key);
|
||||
bool containsKey(String key) => _preferences.containsKey(key);
|
||||
|
||||
Future<void> setBool(String key, bool value) =>
|
||||
_preferences.setBool(key, value);
|
||||
Future<void> setString(String key, String value) =>
|
||||
_preferences.setString(key, value);
|
||||
Future<void> remove(String key) => _preferences.remove(key);
|
||||
}
|
||||
|
||||
final preferencesStorageProvider = Provider<PreferencesStorage>(
|
||||
(ref) => PreferencesStorage(StorageService.to.preferences),
|
||||
);
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'preferences_storage.dart';
|
||||
import 'storage_keys.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
abstract interface class SecureKeyValueStorage {
|
||||
Future<String?> read(String key);
|
||||
@@ -33,25 +32,33 @@ class SecureIdentityStorage {
|
||||
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
||||
}) : _storage = secureStorage;
|
||||
|
||||
static const _accessTokenKey = 'access_token';
|
||||
static const _legacySecureJwtKey = 'jwt_token';
|
||||
|
||||
final SharedPreferences _preferences;
|
||||
final PreferencesStorage _preferences;
|
||||
final SecureKeyValueStorage _storage;
|
||||
|
||||
Future<String?> restoreAccessToken() async {
|
||||
final current = _normalized(await _storage.read(_accessTokenKey));
|
||||
final legacyPreferences = _normalized(
|
||||
_preferences.getString(storageLegacyAccessToken),
|
||||
);
|
||||
final hasInstallSession = _preferences.getBool(storageAuthSessionActive);
|
||||
|
||||
// A plaintext token proves that this installation predates secure storage
|
||||
// migration. A Keychain token by itself does not: iOS may retain it after
|
||||
// uninstall while SharedPreferences (including this marker) is reset.
|
||||
if (!hasInstallSession && legacyPreferences == null) {
|
||||
await _deleteAllAuthCredentials();
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
return null;
|
||||
}
|
||||
|
||||
final current = _normalized(await _storage.read(secureAccessToken));
|
||||
final legacySecure = current == null
|
||||
? _normalized(await _storage.read(_legacySecureJwtKey))
|
||||
? _normalized(await _storage.read(secureLegacyJwtToken))
|
||||
: null;
|
||||
final token = current ?? legacyPreferences ?? legacySecure;
|
||||
if (current == null && token != null) {
|
||||
await _storage.write(_accessTokenKey, token);
|
||||
await _storage.write(secureAccessToken, token);
|
||||
}
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
if (token == null) {
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
@@ -62,10 +69,12 @@ class SecureIdentityStorage {
|
||||
}
|
||||
|
||||
Future<String?> readJwt() async {
|
||||
final token = _normalized(await _storage.read(_accessTokenKey));
|
||||
if (token == null || _preferences.containsKey(storageLegacyAccessToken)) {
|
||||
if (!_preferences.getBool(storageAuthSessionActive) ||
|
||||
_preferences.containsKey(storageLegacyAccessToken)) {
|
||||
return restoreAccessToken();
|
||||
}
|
||||
final token = _normalized(await _storage.read(secureAccessToken));
|
||||
if (token == null) await _preferences.remove(storageAuthSessionActive);
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -74,22 +83,100 @@ class SecureIdentityStorage {
|
||||
if (normalized == null) {
|
||||
throw ArgumentError.value(token, 'token', 'must not be empty');
|
||||
}
|
||||
await _storage.write(_accessTokenKey, normalized);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _storage.write(secureAccessToken, normalized);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.setBool(storageAuthSessionActive, true);
|
||||
}
|
||||
|
||||
Future<void> clearJwt() async {
|
||||
await _storage.delete(_accessTokenKey);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _deleteAllAuthCredentials();
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
}
|
||||
|
||||
Future<void> clearSessionCredentials() async {
|
||||
await clearJwt();
|
||||
await _storage.delete(secureMallCredential);
|
||||
await _storage.delete(secureRefreshToken);
|
||||
}
|
||||
|
||||
Future<void> clearAccountCredentials() async {
|
||||
await clearSessionCredentials();
|
||||
await _storage.delete(secureBiometricCredential);
|
||||
}
|
||||
|
||||
Future<String?> readDeviceId() => _storage.read(secureDeviceId);
|
||||
|
||||
Future<void> saveDeviceId(String deviceId) =>
|
||||
_storage.write(secureDeviceId, deviceId);
|
||||
|
||||
Future<void> _deleteAllAuthCredentials() async {
|
||||
await _storage.delete(secureAccessToken);
|
||||
await _storage.delete(secureLegacyJwtToken);
|
||||
}
|
||||
}
|
||||
|
||||
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
||||
(ref) => SecureIdentityStorage(StorageService.to.preferences),
|
||||
(ref) => SecureIdentityStorage(ref.watch(preferencesStorageProvider)),
|
||||
);
|
||||
|
||||
/// Process-memory session snapshot. Interceptors read this synchronously;
|
||||
/// secure storage is touched only when a session starts, restores, or ends.
|
||||
class AuthSessionStorage {
|
||||
AuthSessionStorage(this._identityStorage);
|
||||
|
||||
final SecureIdentityStorage _identityStorage;
|
||||
String? _accessToken;
|
||||
int _generation = 0;
|
||||
Future<String?>? _restoreFuture;
|
||||
Future<void> _operationTail = Future<void>.value();
|
||||
|
||||
String? get accessToken => _accessToken;
|
||||
int get generation => _generation;
|
||||
|
||||
Future<String?> restore() => _restoreFuture ??= _serialize(_restore);
|
||||
|
||||
Future<String?> _restore() async {
|
||||
_accessToken = await _identityStorage.restoreAccessToken();
|
||||
_generation++;
|
||||
return _accessToken;
|
||||
}
|
||||
|
||||
Future<void> save(String token) async {
|
||||
await _serialize(() async {
|
||||
await _identityStorage.saveJwt(token);
|
||||
_accessToken = token.trim();
|
||||
_generation++;
|
||||
_restoreFuture = Future<String?>.value(_accessToken);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clear({bool accountDeleted = false}) async {
|
||||
await _serialize(() async {
|
||||
if (accountDeleted) {
|
||||
await _identityStorage.clearAccountCredentials();
|
||||
} else {
|
||||
await _identityStorage.clearSessionCredentials();
|
||||
}
|
||||
_accessToken = null;
|
||||
_generation++;
|
||||
_restoreFuture = Future<String?>.value();
|
||||
});
|
||||
}
|
||||
|
||||
Future<T> _serialize<T>(Future<T> Function() operation) {
|
||||
final result = _operationTail.then((_) => operation());
|
||||
_operationTail = result.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace _) {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
final authSessionStorageProvider = Provider<AuthSessionStorage>(
|
||||
(ref) => AuthSessionStorage(ref.watch(secureIdentityStorageProvider)),
|
||||
);
|
||||
|
||||
String? _normalized(String? value) {
|
||||
|
||||
@@ -2,6 +2,7 @@ const storageThemeMode = 'theme_mode';
|
||||
const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控)
|
||||
const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示
|
||||
const storageJinzhiHoldings = 'jinzhi_holdings';
|
||||
const storageJinzhiHoldingsDeleted = 'jinzhi_holdings_deleted';
|
||||
const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
|
||||
const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
|
||||
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||
@@ -18,6 +19,16 @@ const storageMallHomeBadgeSeen = 'jinzhi_mall_home_badge_seen';
|
||||
const storageLegacyAccessToken = 'access_token';
|
||||
const storageAuthSessionActive = 'auth_session_active';
|
||||
|
||||
// Account-scoped secure values. They are intentionally listed even before
|
||||
// every feature is implemented so account deletion has one complete cleanup
|
||||
// boundary. Access tokens must never be reused as biometric credentials.
|
||||
const secureAccessToken = 'access_token';
|
||||
const secureLegacyJwtToken = 'jwt_token';
|
||||
const secureDeviceId = 'device_id';
|
||||
const secureMallCredential = 'mall_credential';
|
||||
const secureRefreshToken = 'refresh_token';
|
||||
const secureBiometricCredential = 'biometric_credential';
|
||||
|
||||
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
||||
// header;未写入则为空、注入时被过滤。
|
||||
const storageDeviceToken = 'device_token'; // 推送 token
|
||||
|
||||
@@ -16,7 +16,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
this.alignment = Alignment.center,
|
||||
this.borderRadius,
|
||||
this.cacheKey,
|
||||
this.httpHeaders,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
this.semanticLabel,
|
||||
@@ -29,7 +28,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
final Alignment alignment;
|
||||
final BorderRadius? borderRadius;
|
||||
final String? cacheKey;
|
||||
final Map<String, String>? httpHeaders;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
final String? semanticLabel;
|
||||
@@ -41,7 +39,6 @@ class AppNetworkImage extends StatelessWidget {
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
httpHeaders: httpHeaders,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../network/api_error_message.dart';
|
||||
import 'app_state_card.dart';
|
||||
|
||||
/// Shared loading, failure and empty-state contract for feature pages.
|
||||
@@ -37,11 +38,11 @@ class AsyncStateView<T> extends StatelessWidget {
|
||||
return state.when(
|
||||
loading: () =>
|
||||
loading ?? const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
error: (error, _) => Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: errorTitle,
|
||||
subtitle: errorSubtitle,
|
||||
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
@@ -60,3 +61,44 @@ class AsyncStateView<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sliver counterpart for CustomScrollView pages without forcing one global
|
||||
/// card layout on successful feature content.
|
||||
class SliverAsyncState<T> extends StatelessWidget {
|
||||
const SliverAsyncState({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.data,
|
||||
this.loading,
|
||||
this.onRetry,
|
||||
this.errorSubtitle = '请检查网络后重试',
|
||||
});
|
||||
|
||||
final AsyncValue<T> state;
|
||||
final Widget Function(T value) data;
|
||||
final Widget? loading;
|
||||
final VoidCallback? onRetry;
|
||||
final String errorSubtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => state.when(
|
||||
data: data,
|
||||
loading: () => SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: loading ?? const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, _) => SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: '加载失败',
|
||||
subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle),
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user