fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案

This commit is contained in:
jingyun committed 2026-09-10 09:52:36 +08:00
1 parent 6997a65c96
commit d364c6d908
46 files changed
+2060 -395

No files matched your search

@@ -4,7 +4,7 @@ import 'dart:io' show File;
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/storage/storage_service.dart';
import '../../../common/storage/preferences_storage.dart';
import '../data/ads_api_client.dart';
class SplashAdEntry {
@@ -33,12 +33,13 @@ class SplashAdEntry {
}
class SplashAdController {
SplashAdController(this._api);
SplashAdController(this._api, this._preferences);
final AdsApiClient _api;
final PreferencesStorage _preferences;
static const _entryKey = 'ads.splash.entry';
static const _dailyKey = 'ads.splash.daily';
Future<SplashAdEntry?> coldLaunchDecision() async {
final raw = StorageService.to.preferences.getString(_entryKey);
final raw = _preferences.getString(_entryKey);
if (raw == null) return null;
try {
final entry = SplashAdEntry.fromJson(
@@ -55,7 +56,7 @@ class SplashAdController {
}
bool _underCap(SplashAdEntry entry) {
final value = StorageService.to.preferences.getString(_dailyKey);
final value = _preferences.getString(_dailyKey);
final today = DateTime.now().toIso8601String().substring(0, 10);
if (value == null) return true;
final map = Map<String, dynamic>.from(jsonDecode(value) as Map);
@@ -68,13 +69,13 @@ class SplashAdController {
entry.assetPath != null && await File(entry.assetPath!).exists();
Future<void> markShown(SplashAdEntry entry) async {
final today = DateTime.now().toIso8601String().substring(0, 10);
final raw = StorageService.to.preferences.getString(_dailyKey);
final raw = _preferences.getString(_dailyKey);
var count = 0;
if (raw != null) {
final m = Map<String, dynamic>.from(jsonDecode(raw) as Map);
if (m['date'] == today) count = (m['count'] as num?)?.toInt() ?? 0;
}
await StorageService.to.preferences.setString(
await _preferences.setString(
_dailyKey,
jsonEncode({'date': today, 'count': count + 1}),
);
@@ -92,7 +93,7 @@ class SplashAdController {
result.ad!.imageUrl,
);
if (!await file.exists()) return;
await StorageService.to.preferences.setString(
await _preferences.setString(
_entryKey,
jsonEncode(
SplashAdEntry(
@@ -115,14 +116,17 @@ class SplashAdController {
Future<void> skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip');
Future<void> clearCache() async {
await _clearEntry();
await StorageService.to.preferences.remove(_dailyKey);
await _preferences.remove(_dailyKey);
}
Future<void> _clearEntry() async {
await StorageService.to.preferences.remove(_entryKey);
await _preferences.remove(_entryKey);
}
}
final splashAdControllerProvider = Provider(
(ref) => SplashAdController(ref.watch(adsApiClientProvider)),
(ref) => SplashAdController(
ref.watch(adsApiClientProvider),
ref.watch(preferencesStorageProvider),
),
);
+72 -138
View File
@@ -1,11 +1,17 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:fixnum/fixnum.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 'package:uuid/uuid.dart';
import '../../../common/network/dio_provider.dart';
import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb;
import '../../../models/pb/jinzhi/v1/ads.pb.dart'
show GetAdReply, ListAdsReply, ReportAdEventReply, ReportAdEventRequest;
part 'ads_api_client.g.dart';
const splashAdPlacement = 'splash';
const homeBannerAdPlacement = 'home_banner';
@@ -100,24 +106,26 @@ class AdCreative {
? AdOpenMode.inApp
: AdOpenMode.external,
);
factory AdCreative.fromProto(ProtoMessage fields) => AdCreative(
adId: fields.string(1),
placement: fields.string(2),
targetApp: fields.string(3),
title: fields.string(4),
imageUrl: fields.string(6),
mediaType: switch (fields.integer(7)) {
1 => AdMediaType.image,
2 => AdMediaType.gif,
3 => AdMediaType.video,
factory AdCreative.fromProto(pb.AdCreative creative) => AdCreative(
adId: creative.adId,
placement: creative.placement,
targetApp: creative.targetApp,
title: creative.title,
imageUrl: creative.imageUrl,
mediaType: switch (creative.mediaType) {
pb.MediaType.MEDIA_TYPE_IMAGE => AdMediaType.image,
pb.MediaType.MEDIA_TYPE_GIF => AdMediaType.gif,
pb.MediaType.MEDIA_TYPE_VIDEO => AdMediaType.video,
_ => AdMediaType.unsupported,
},
h5Url: fields.string(9),
trackingId: fields.string(10),
countdownSeconds: fields.integer(11),
endAtMs: fields.integer(13),
trackedUrl: fields.string(14),
openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external,
h5Url: creative.h5Url,
trackingId: creative.trackingId,
countdownSeconds: creative.countdownSeconds,
endAtMs: creative.endAtMs.toInt(),
trackedUrl: creative.trackedUrl,
openMode: creative.openMode == pb.AdOpenMode.AD_OPEN_MODE_IN_APP
? AdOpenMode.inApp
: AdOpenMode.external,
);
}
@@ -136,41 +144,49 @@ class FeaturedAdsState {
bool get hasAds => enabled && ads.isNotEmpty;
}
@RestApi()
abstract class AdsBackendApiClient {
factory AdsBackendApiClient(Dio dio, {String? baseUrl}) =
_AdsBackendApiClient;
@GET('/api/v1/ads')
Future<pb.GetAdReply> getAd({@Query('placement') required String placement});
@GET('/api/v1/ads/list')
Future<pb.ListAdsReply> listAds({
@Query('placement') required String placement,
});
@POST('/api/v1/ads/events')
Future<pb.ReportAdEventReply> reportAdEvent(
@Body() pb.ReportAdEventRequest body,
);
}
class AdsApiClient {
AdsApiClient(this._dio);
final Dio _dio;
AdsApiClient(this._api);
final AdsBackendApiClient _api;
Future<GetAdResult> getAdResult(String placement) async {
final response = await _dio.get<List<int>>(
'/api/v1/ads',
queryParameters: {'placement': placement},
options: protobufOptions(),
);
final root = ProtoMessage(response.data ?? const []);
final creative = root.message(2);
final ad = AdCreative.fromProto(creative);
final reply = await _api.getAd(placement: placement);
final ad = reply.hasAd() ? AdCreative.fromProto(reply.ad) : null;
return GetAdResult(
root.integer(1) != 0,
ad.isRenderable ? ad : null,
root.integer(3),
reply.enabled,
ad?.isRenderable == true ? ad : null,
reply.dailyCap,
);
}
Future<FeaturedAdsState> listAds(String placement) async {
final response = await _dio.get<List<int>>(
'/api/v1/ads/list',
queryParameters: {'placement': placement},
options: protobufOptions(),
);
final root = ProtoMessage(response.data ?? const []);
final ads = root
.messages(2)
final reply = await _api.listAds(placement: placement);
final ads = reply.ads
.map(AdCreative.fromProto)
.where((ad) => ad.isRenderable)
.toList(growable: false);
return FeaturedAdsState(
root.integer(1) != 0,
reply.enabled,
ads,
trackingId: root.string(3).isEmpty ? null : root.string(3),
trackingId: reply.trackingId.isEmpty ? null : reply.trackingId,
);
}
@@ -179,23 +195,24 @@ class AdsApiClient {
String event, {
String? clickId,
String? trackingId,
}) => _dio.post<List<int>>(
'/api/v1/ads/events',
data: _Writer()
.string(1, event)
.string(2, ad.placement)
.string(3, ad.adId)
.string(4, trackingId ?? ad.trackingId)
.string(5, clickId ?? '')
.integer(6, DateTime.now().millisecondsSinceEpoch)
.toBytes(),
options: protobufOptions(),
);
}) async {
await _api.reportAdEvent(
pb.ReportAdEventRequest(
eventName: event,
placement: ad.placement,
adId: ad.adId,
trackingId: trackingId ?? ad.trackingId,
clickId: clickId ?? '',
clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch),
),
);
}
String newClickId() => const Uuid().v4();
}
final adsApiClientProvider = Provider(
(ref) => AdsApiClient(ref.watch(dioProvider)),
(ref) => AdsApiClient(AdsBackendApiClient(ref.watch(backendDioProvider))),
);
final featuredAdsProvider = FutureProvider.autoDispose
.family<FeaturedAdsState, String>(
@@ -210,86 +227,3 @@ Uri? _http(String raw) {
? uri
: null;
}
class ProtoMessage {
ProtoMessage(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) : '';
}
ProtoMessage message(int tag) {
final value = _fields[tag]?.lastOrNull;
return ProtoMessage(value is List<int> ? value : const []);
}
List<ProtoMessage> messages(int tag) => (_fields[tag] ?? const [])
.whereType<List<int>>()
.map(ProtoMessage.new)
.toList();
Map<int, List<Object>> _read() {
final out = <int, List<Object>>{};
var i = 0;
int v() {
var n = 0, s = 0;
while (i < bytes.length) {
final b = bytes[i++];
n |= (b & 127) << s;
if (b < 128) return n;
s += 7;
}
throw const FormatException('Invalid protobuf');
}
while (i < bytes.length) {
final key = v(), tag = key >> 3;
Object value;
if ((key & 7) == 0) {
value = v();
} else if ((key & 7) == 2) {
final l = v(), end = i + l;
if (end > bytes.length) throw const FormatException('Invalid protobuf');
value = bytes.sublist(i, end);
i = end;
} else {
throw const FormatException('Unsupported protobuf');
}
(out[tag] ??= []).add(value);
}
return out;
}
}
extension on List<Object> {
Object? get lastOrNull => isEmpty ? null : last;
}
class _Writer {
final b = BytesBuilder(copy: false);
_Writer string(int t, String value) {
final d = value.codeUnits;
v((t << 3) | 2);
v(d.length);
b.add(d);
return this;
}
_Writer integer(int t, int value) {
v(t << 3);
v(value);
return this;
}
Uint8List toBytes() => b.toBytes();
void v(int n) {
while (n > 127) {
b.addByte((n & 127) | 128);
n >>= 7;
}
b.addByte(n);
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../common/widgets/app_network_image.dart';
import '../data/ads_api_client.dart';
/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。
@@ -86,12 +87,12 @@ class _AdMediaState extends State<AdMedia> {
errorBuilder: (_, _, _) => _error(),
);
}
return Image.network(
widget.ad.imageUrl,
return AppNetworkImage(
imageUrl: widget.ad.imageUrl,
fit: widget.fit,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, _, _) => _error(),
errorWidget: _error(),
);
}
final c = player;
@@ -1,16 +1,29 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import '../../auth/application/auth_controller.dart';
import '../../auth/data/auth_models.dart';
import '../../market/data/mock_market_repository.dart';
import '../data/asset_models.dart';
import '../data/mock_asset_repository.dart';
final assetPortfolioControllerProvider =
StateNotifierProvider<AssetPortfolioController, PortfolioSummary>((ref) {
final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings();
final repository = ref.watch(mockAssetRepositoryProvider);
final authState = ref.watch(authProvider).valueOrNull;
final accountScope = switch (authState) {
LoggedInAuthState(:final user) => MockAssetRepository.accountScope(
userId: user.id,
phone: user.phone,
),
_ => MockAssetRepository.guestAccountScope,
};
final holdings = repository.loadHoldings(accountScope);
final market = ref.watch(mockMarketRepositoryProvider);
return AssetPortfolioController(
holdings: holdings,
repository: repository,
accountScope: accountScope,
spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
changeFor: (metal) => market.quoteFor(metal).changeAmount,
);
@@ -19,10 +32,14 @@ final assetPortfolioControllerProvider =
class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
AssetPortfolioController({
required List<GoldAssetHolding> holdings,
required MockAssetRepository repository,
required String accountScope,
required double Function(MetalType metal) spotPriceFor,
required double Function(MetalType metal) changeFor,
this.recycleDiscount = 0.97,
}) : _holdings = holdings,
_repository = repository,
_accountScope = accountScope,
_spotPriceFor = spotPriceFor,
_changeFor = changeFor,
super(
@@ -35,12 +52,25 @@ class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
);
final List<GoldAssetHolding> _holdings;
final MockAssetRepository _repository;
final String _accountScope;
final double Function(MetalType metal) _spotPriceFor;
final double Function(MetalType metal) _changeFor;
final double recycleDiscount;
void addHolding(GoldAssetHolding holding) {
Future<void> addHolding(GoldAssetHolding holding) async {
_holdings.add(holding);
await _repository.saveHoldings(_accountScope, _holdings);
state = _buildSummary(
holdings: _holdings,
spotPriceFor: _spotPriceFor,
changeFor: _changeFor,
recycleDiscount: recycleDiscount,
);
}
void clearHoldings() {
_holdings.clear();
state = _buildSummary(
holdings: _holdings,
spotPriceFor: _spotPriceFor,
@@ -51,6 +51,45 @@ class GoldAssetHolding {
final HoldingStatus status;
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'metal': metal.name,
'category': category.name,
'purity': purity,
'purityLabel': purityLabel,
'weightGram': weightGram,
'costAmount': costAmount,
'purchaseDate': purchaseDate?.toIso8601String(),
'channel': channel,
'note': note,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'status': status.name,
};
factory GoldAssetHolding.fromJson(Map<String, Object?> json) {
return GoldAssetHolding(
id: json['id']! as String,
name: json['name']! as String,
metal: MetalType.values.byName(json['metal']! as String),
category: AssetCategory.values.byName(json['category']! as String),
purity: (json['purity']! as num).toDouble(),
purityLabel: json['purityLabel']! as String,
weightGram: (json['weightGram']! as num).toDouble(),
costAmount: (json['costAmount'] as num?)?.toDouble(),
purchaseDate: switch (json['purchaseDate']) {
final String value when value.isNotEmpty => DateTime.parse(value),
_ => null,
},
channel: json['channel'] as String?,
note: json['note'] as String?,
createdAt: DateTime.parse(json['createdAt']! as String),
updatedAt: DateTime.parse(json['updatedAt']! as String),
status: HoldingStatus.values.byName(json['status']! as String),
);
}
}
class HoldingValuation {
@@ -1,14 +1,91 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import '../../../common/storage/preferences_storage.dart';
import '../../../common/storage/storage_keys.dart';
import 'asset_models.dart';
final mockAssetRepositoryProvider = Provider<MockAssetRepository>(
(ref) => MockAssetRepository(),
(ref) => MockAssetRepository(ref.watch(preferencesStorageProvider)),
);
class MockAssetRepository {
List<GoldAssetHolding> loadHoldings() {
MockAssetRepository(this._preferences);
static const guestAccountScope = 'guest';
final PreferencesStorage _preferences;
Future<void> _operationTail = Future<void>.value();
static String accountScope({String? userId, String? phone}) {
final normalizedUserId = userId?.trim() ?? '';
if (normalizedUserId.isNotEmpty) return 'id:$normalizedUserId';
final normalizedPhone = phone?.trim() ?? '';
if (normalizedPhone.isNotEmpty) return 'phone:$normalizedPhone';
return guestAccountScope;
}
static String holdingsStorageKey(String accountScope) =>
'$storageJinzhiHoldings:${Uri.encodeComponent(accountScope)}';
static String deletedStorageKey(String accountScope) =>
'$storageJinzhiHoldingsDeleted:${Uri.encodeComponent(accountScope)}';
List<GoldAssetHolding> loadHoldings(String accountScope) {
if (_preferences.getBool(deletedStorageKey(accountScope))) return [];
final encoded = _preferences.getString(holdingsStorageKey(accountScope));
if (encoded == null || encoded.isEmpty) return _seedHoldings();
try {
final decoded = jsonDecode(encoded);
if (decoded is! List) return _seedHoldings();
return [
for (final item in decoded)
GoldAssetHolding.fromJson(Map<String, Object?>.from(item as Map)),
];
} catch (_) {
return _seedHoldings();
}
}
Future<void> saveHoldings(
String accountScope,
List<GoldAssetHolding> holdings,
) async {
final encoded = jsonEncode([
for (final holding in holdings) holding.toJson(),
]);
await _serialize(() async {
await _preferences.setString(holdingsStorageKey(accountScope), encoded);
await _preferences.remove(deletedStorageKey(accountScope));
});
}
Future<void> deleteAccountHoldings(String accountScope) async {
await _serialize(() async {
final scopes = {accountScope, guestAccountScope};
await Future.wait([
for (final scope in scopes) ...[
_preferences.remove(holdingsStorageKey(scope)),
_preferences.setBool(deletedStorageKey(scope), true),
],
// Remove the pre-account-scoping key left by earlier builds.
_preferences.remove(storageJinzhiHoldings),
]);
});
}
Future<T> _serialize<T>(Future<T> Function() operation) {
final result = _operationTail.then((_) => operation());
_operationTail = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
List<GoldAssetHolding> _seedHoldings() {
final now = DateTime(2026, 6, 18, 16, 11);
return [
GoldAssetHolding(
@@ -1,13 +1,13 @@
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/network/api_exception.dart';
import '../../../common/network/dio_provider.dart';
import '../../../common/storage/secure_identity_storage.dart';
import '../../../common/storage/preferences_storage.dart';
import '../../../common/storage/storage_keys.dart';
import '../../../common/storage/storage_service.dart';
import '../data/auth_api.dart';
import '../data/auth_models.dart';
import 'auth_session_coordinator.dart';
const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in';
@@ -15,26 +15,27 @@ final authProvider =
StateNotifierProvider<AuthController, AsyncValue<AuthState>>((ref) {
return AuthController(
ref.read(dioProvider),
identityStorage: ref.read(secureIdentityStorageProvider),
preferences: StorageService.to.preferences,
preferences: ref.read(preferencesStorageProvider),
coordinator: ref.read(authSessionCoordinatorProvider),
);
});
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
AuthController(
this._dio, {
required SecureIdentityStorage identityStorage,
required SharedPreferences preferences,
required PreferencesStorage preferences,
required AuthSessionCoordinator coordinator,
bool autoBootstrap = true,
}) : _identityStorage = identityStorage,
_preferences = preferences,
}) : _preferences = preferences,
_coordinator = coordinator,
super(const AsyncLoading()) {
_coordinator.addListener(_onSessionEnded);
if (autoBootstrap) _bootstrap();
}
final Dio _dio;
final SecureIdentityStorage _identityStorage;
final SharedPreferences _preferences;
final PreferencesStorage _preferences;
final AuthSessionCoordinator _coordinator;
AuthApi get _api => AuthApi(_dio);
@@ -42,28 +43,40 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
Future<void> _bootstrap() async {
state = const AsyncLoading();
final token = await _identityStorage.readJwt();
final token = await _coordinator.restore();
if (token == null || token.isEmpty) {
state = const AsyncData(GuestAuthState());
return;
}
final cached = _readCachedProfile();
if (cached != null) {
state = AsyncData(
LoggedInAuthState(user: cached, token: token, isVerified: false),
);
await _persistProfile(cached);
}
await _verifyProfile(token, _coordinator.generation);
}
Future<void> _verifyProfile(String token, int generation) async {
try {
final user = await _api.getProfile();
if (generation != _coordinator.generation) return;
await _persistProfile(user);
state = AsyncData(LoggedInAuthState(user: user, token: token));
state = AsyncData(
LoggedInAuthState(user: user, token: token, isVerified: true),
);
} catch (error, stackTrace) {
if (_isUnauthorized(error)) {
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
if (generation != _coordinator.generation) return;
final exception = ApiException.from(error, stackTrace: stackTrace);
if (await _coordinator.expireIfNeeded(exception)) {
return;
}
final cached = _canUseCachedProfile(error) ? _readCachedProfile() : null;
if (cached != null) {
await _persistProfile(cached);
state = AsyncData(LoggedInAuthState(user: cached, token: token));
} else {
state = AsyncError(error, stackTrace);
}
// Any non-session error (transport, 403, 429, 5xx, decode) preserves a
// stale cached session. With no profile there is nothing safe to render,
// but the credential still remains available for retry.
if (state.valueOrNull is LoggedInAuthState) return;
state = AsyncError(exception, stackTrace);
}
}
@@ -80,7 +93,7 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
}
Future<void> _persistLogin(AuthLoginResult reply) async {
await _identityStorage.saveJwt(reply.accessToken);
await _coordinator.startSession(reply.accessToken);
await _persistProfile(reply.user);
state = AsyncData(
LoggedInAuthState(user: reply.user, token: reply.accessToken),
@@ -88,8 +101,7 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
}
Future<void> logout() async {
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
await _coordinator.logout();
}
Future<void> deleteAccount(String code) async {
@@ -98,8 +110,7 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
throw ArgumentError.value(code, 'code', 'must not be empty');
}
await _api.deleteAccount(normalized);
await _clearLocalSession();
state = const AsyncData(GuestAuthState());
await _coordinator.accountDeleted();
}
Future<void> _persistProfile(JinzhiUser user) async {
@@ -122,22 +133,19 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
}
Future<void> _clearLocalSession() async {
await _identityStorage.clearJwt();
await _preferences.remove(storageUserId);
await _preferences.remove(storageUserPhone);
await _preferences.remove(storageUserCreatedAt);
await _preferences.remove(_legacyMockLoggedInKey);
void _onSessionEnded(SessionEndReason reason) {
state = AsyncData(
reason == SessionEndReason.expired
? const SessionExpiredAuthState()
: const GuestAuthState(),
);
}
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
}
bool _isUnauthorized(Object error) =>
error is DioException && error.response?.statusCode == 401;
bool _canUseCachedProfile(Object error) {
if (error is! DioException) return false;
final status = error.response?.statusCode;
return status == null || status == 408 || status == 429 || status >= 500;
@override
void dispose() {
_coordinator.removeListener(_onSessionEnded);
super.dispose();
}
}
@@ -0,0 +1,122 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/network/api_exception.dart';
import '../../../common/storage/preferences_storage.dart';
import '../../../common/storage/secure_identity_storage.dart';
import '../../../common/storage/storage_keys.dart';
import '../../assets/data/mock_asset_repository.dart';
enum SessionEndReason { logout, expired, accountDeleted }
typedef SessionEndListener = void Function(SessionEndReason reason);
/// Owns session invalidation without depending on Navigator, Router or UI.
class AuthSessionCoordinator {
AuthSessionCoordinator(
this._sessionStorage,
this._preferences,
this._assetRepository,
);
final AuthSessionStorage _sessionStorage;
final PreferencesStorage _preferences;
final MockAssetRepository _assetRepository;
final Set<SessionEndListener> _listeners = {};
Future<void>? _ending;
SessionEndReason? _endingReason;
int get generation => _sessionStorage.generation;
void addListener(SessionEndListener listener) => _listeners.add(listener);
void removeListener(SessionEndListener listener) =>
_listeners.remove(listener);
Future<String?> restore() => _sessionStorage.restore();
Future<void> startSession(String token) async {
final ending = _ending;
if (ending != null) await ending;
await _sessionStorage.save(token);
}
Future<bool> expireIfNeeded(ApiException exception) async {
if (!exception.isUnauthorized) return false;
final requestGeneration = exception.sessionGeneration;
if (requestGeneration != null && requestGeneration != generation) {
return false;
}
await end(SessionEndReason.expired);
return true;
}
Future<void> logout() => end(SessionEndReason.logout);
Future<void> accountDeleted() => end(SessionEndReason.accountDeleted);
Future<void> end(SessionEndReason reason) {
_endingReason = _strongerReason(_endingReason, reason);
final active = _ending;
if (active != null) return active;
final operation = _drainEnd();
late final Future<void> tracked;
tracked = operation.whenComplete(() {
if (identical(_ending, tracked)) {
_ending = null;
_endingReason = null;
}
});
_ending = tracked;
return tracked;
}
Future<void> _drainEnd() async {
final assetAccountScope = MockAssetRepository.accountScope(
userId: _preferences.getString(storageUserId),
phone: _preferences.getString(storageUserPhone),
);
SessionEndReason? completedReason;
while (completedReason != _endingReason) {
final reason = _endingReason!;
await _sessionStorage.clear(
accountDeleted: reason == SessionEndReason.accountDeleted,
);
await Future.wait([
_preferences.remove(storageUserId),
_preferences.remove(storageUserPhone),
_preferences.remove(storageUserCreatedAt),
_preferences.remove('jinzhi_mock_logged_in'),
if (reason == SessionEndReason.accountDeleted)
_assetRepository.deleteAccountHoldings(assetAccountScope),
]);
completedReason = reason;
}
final finalReason = _endingReason!;
for (final listener in List<SessionEndListener>.of(_listeners)) {
listener(finalReason);
}
}
}
SessionEndReason _strongerReason(
SessionEndReason? current,
SessionEndReason next,
) {
if (current == null) return next;
return _reasonPriority(next) > _reasonPriority(current) ? next : current;
}
int _reasonPriority(SessionEndReason reason) => switch (reason) {
SessionEndReason.expired => 0,
SessionEndReason.logout => 1,
SessionEndReason.accountDeleted => 2,
};
final authSessionCoordinatorProvider = Provider<AuthSessionCoordinator>(
(ref) => AuthSessionCoordinator(
ref.watch(authSessionStorageProvider),
ref.watch(preferencesStorageProvider),
ref.watch(mockAssetRepositoryProvider),
),
);
+10 -1
View File
@@ -20,9 +20,18 @@ class GuestAuthState extends AuthState {
const GuestAuthState();
}
class SessionExpiredAuthState extends AuthState {
const SessionExpiredAuthState();
}
class LoggedInAuthState extends AuthState {
const LoggedInAuthState({required this.user, required this.token});
const LoggedInAuthState({
required this.user,
required this.token,
this.isVerified = true,
});
final JinzhiUser user;
final String token;
final bool isVerified;
}
@@ -1,17 +1,14 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/storage/preferences_storage.dart';
import '../../../common/storage/storage_keys.dart';
class MallEntryPromoStore {
const MallEntryPromoStore(this._preferences);
final SharedPreferences _preferences;
final PreferencesStorage _preferences;
bool get hasOpenedMall =>
_preferences.getBool(storageMallEntryPromoOpened) ?? false;
bool get hasOpenedMall => _preferences.getBool(storageMallEntryPromoOpened);
bool get hasSeenHomeBadge =>
_preferences.getBool(storageMallHomeBadgeSeen) ?? false;
bool get hasSeenHomeBadge => _preferences.getBool(storageMallHomeBadgeSeen);
Future<void> markMallOpened() async {
await _preferences.setBool(storageMallEntryPromoOpened, true);
+2 -2
View File
@@ -4,7 +4,7 @@ import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/network/dio_provider.dart';
import '../../ads/data/ads_api_client.dart';
import '../../../common/network/protobuf_wire_reader.dart';
class AppMallAuthToken {
const AppMallAuthToken({
@@ -33,7 +33,7 @@ class MallAuthApi {
data: Uint8List(0),
options: protobufOptions(),
);
final reply = ProtoMessage(response.data ?? const <int>[]);
final reply = ProtobufWireMessage(response.data ?? const <int>[]);
final token = reply.string(1).trim();
final expiresIn = reply.integer(2);
final sourceApp = reply.string(3).trim();
@@ -48,6 +48,8 @@ class UpgradeInfo {
}
}
// BLOCKED(proto): this endpoint has no authoritative generated Upgrade proto.
// Keep its existing wire contract until that source becomes available.
class UpdateApiClient {
UpdateApiClient(this._dio);
+33 -26
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
@@ -31,32 +33,37 @@ class _AppWebPageState extends State<AppWebPage> {
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'JinzhiBridge',
onMessageReceived: (message) {
if (message.message == 'close' && mounted) context.pop();
},
)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (progress) => setState(() => _progress = progress),
onPageFinished: (_) async {
final title = await _controller.getTitle();
if (mounted && title != null && title.trim().isNotEmpty) {
setState(() => _title = title.trim());
}
},
onWebResourceError: (error) {
if (error.isForMainFrame == true && mounted) {
setState(() => _error = error.description);
}
},
onNavigationRequest: _onNavigationRequest,
),
)
..loadRequest(widget.initialUri);
_controller =
WebViewController(
// Ordinary pages never receive camera/microphone or other protected
// resources. MallWebHost owns its separate permission policy.
onPermissionRequest: (request) => unawaited(request.deny()),
)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'JinzhiBridge',
onMessageReceived: (message) {
if (message.message == 'close' && mounted) context.pop();
},
)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (progress) => setState(() => _progress = progress),
onPageFinished: (_) async {
final title = await _controller.getTitle();
if (mounted && title != null && title.trim().isNotEmpty) {
setState(() => _title = title.trim());
}
},
onWebResourceError: (error) {
if (error.isForMainFrame == true && mounted) {
setState(() => _error = error.description);
}
},
onNavigationRequest: _onNavigationRequest,
),
)
..loadRequest(widget.initialUri);
}
NavigationDecision _onNavigationRequest(NavigationRequest request) {