fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案
This commit is contained in:
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),
|
||||
),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user