fix:第一轮跑
This commit is contained in:
1 parent
e834ec7586
commit
33235f450b
56 files changed
+4357
-210
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import '../../mall/application/mall_tab_config.dart';
|
||||
import '../data/ads_api_client.dart';
|
||||
import 'splash_ad_controller.dart';
|
||||
|
||||
final adConfigForegroundRefresherProvider = Provider(
|
||||
(ref) => AdConfigForegroundRefresher(
|
||||
() => ref.invalidate(featuredAdsProvider(homeBannerAdPlacement)),
|
||||
() => ref.read(splashAdControllerProvider).refresh(),
|
||||
() => ref.read(mallTabConfigProvider.notifier).refresh(),
|
||||
),
|
||||
);
|
||||
|
||||
class AdConfigForegroundRefresher {
|
||||
AdConfigForegroundRefresher(this._banner, this._splash, this._mall);
|
||||
final void Function() _banner;
|
||||
final Future<void> Function() _splash, _mall;
|
||||
Future<void>? _running;
|
||||
Future<void> refresh() =>
|
||||
_running ??= _refresh().whenComplete(() => _running = null);
|
||||
Future<void> _refresh() async {
|
||||
try {
|
||||
_banner();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _splash();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _mall();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:convert';
|
||||
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 '../data/ads_api_client.dart';
|
||||
|
||||
class SplashAdEntry {
|
||||
const SplashAdEntry(this.ad, this.dailyCap, this.assetPath, this.cachedAtMs);
|
||||
final AdCreative ad;
|
||||
final int dailyCap;
|
||||
final String? assetPath;
|
||||
final int cachedAtMs;
|
||||
bool get valid =>
|
||||
ad.isRenderable &&
|
||||
DateTime.now().millisecondsSinceEpoch <
|
||||
cachedAtMs + const Duration(hours: 24).inMilliseconds &&
|
||||
(ad.endAtMs <= 0 || ad.endAtMs > DateTime.now().millisecondsSinceEpoch);
|
||||
Map<String, dynamic> toJson() => {
|
||||
'ad': ad.toJson(),
|
||||
'dailyCap': dailyCap,
|
||||
'assetPath': assetPath,
|
||||
'cachedAtMs': cachedAtMs,
|
||||
};
|
||||
factory SplashAdEntry.fromJson(Map<String, dynamic> map) => SplashAdEntry(
|
||||
AdCreative.fromJson(Map<String, dynamic>.from(map['ad'] as Map)),
|
||||
(map['dailyCap'] as num?)?.toInt() ?? 0,
|
||||
map['assetPath'] as String?,
|
||||
(map['cachedAtMs'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class SplashAdController {
|
||||
SplashAdController(this._api);
|
||||
final AdsApiClient _api;
|
||||
static const _entryKey = 'ads.splash.entry';
|
||||
static const _dailyKey = 'ads.splash.daily';
|
||||
Future<SplashAdEntry?> coldLaunchDecision() async {
|
||||
final raw = StorageService.to.preferences.getString(_entryKey);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
final entry = SplashAdEntry.fromJson(
|
||||
Map<String, dynamic>.from(jsonDecode(raw) as Map),
|
||||
);
|
||||
if (!entry.valid || !_underCap(entry) || !await _ready(entry)) {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
} catch (_) {
|
||||
await clearCache();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _underCap(SplashAdEntry entry) {
|
||||
final value = StorageService.to.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);
|
||||
return map['date'] != today ||
|
||||
entry.dailyCap <= 0 ||
|
||||
((map['count'] as num?)?.toInt() ?? 0) < entry.dailyCap;
|
||||
}
|
||||
|
||||
Future<bool> _ready(SplashAdEntry entry) async =>
|
||||
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);
|
||||
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(
|
||||
_dailyKey,
|
||||
jsonEncode({'date': today, 'count': count + 1}),
|
||||
);
|
||||
await _report(entry.ad, 'ad_impression');
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final result = await _api.getAdResult(splashAdPlacement);
|
||||
if (!result.enabled || result.ad == null) {
|
||||
await _clearEntry();
|
||||
return;
|
||||
}
|
||||
final file = await DefaultCacheManager().getSingleFile(
|
||||
result.ad!.imageUrl,
|
||||
);
|
||||
if (!await file.exists()) return;
|
||||
await StorageService.to.preferences.setString(
|
||||
_entryKey,
|
||||
jsonEncode(
|
||||
SplashAdEntry(
|
||||
result.ad!,
|
||||
result.dailyCap,
|
||||
file.path,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
).toJson(),
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _report(AdCreative ad, String event, {String? clickId}) async {
|
||||
try {
|
||||
await _api.report(ad, event, clickId: clickId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip');
|
||||
Future<void> clearCache() async {
|
||||
await _clearEntry();
|
||||
await StorageService.to.preferences.remove(_dailyKey);
|
||||
}
|
||||
|
||||
Future<void> _clearEntry() async {
|
||||
await StorageService.to.preferences.remove(_entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
final splashAdControllerProvider = Provider(
|
||||
(ref) => SplashAdController(ref.watch(adsApiClientProvider)),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../data/ads_api_client.dart';
|
||||
import '../presentation/splash_ad_dialog.dart';
|
||||
import 'splash_ad_controller.dart';
|
||||
|
||||
final startupAdResolvedProvider = StateProvider<bool>((_) => false);
|
||||
|
||||
abstract final class StartupAdService {
|
||||
static Future<void>? _running;
|
||||
static Future<void> showIfAvailable(BuildContext context, WidgetRef ref) =>
|
||||
_running ??= _show(context, ref);
|
||||
static Future<void> _show(BuildContext context, WidgetRef ref) async {
|
||||
final controller = ref.read(splashAdControllerProvider);
|
||||
try {
|
||||
final entry = await controller.coldLaunchDecision();
|
||||
if (entry != null && context.mounted) {
|
||||
final result = await showGeneralDialog<SplashAdResult>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.black,
|
||||
pageBuilder: (ctx, _, _) => SplashAdDialog(
|
||||
entry: entry,
|
||||
onShown: () => unawaited(controller.markShown(entry)),
|
||||
onClick: () async {
|
||||
final id = ref.read(adsApiClientProvider).newClickId();
|
||||
final uri = entry.ad.clickUri(id);
|
||||
if (uri != null) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(adsApiClientProvider)
|
||||
.report(entry.ad, 'ad_click', clickId: id),
|
||||
);
|
||||
if (entry.ad.openMode == AdOpenMode.inApp) {
|
||||
context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}',
|
||||
);
|
||||
} else {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
if (result == SplashAdResult.skipped) unawaited(controller.skip(entry));
|
||||
}
|
||||
} finally {
|
||||
ref.read(startupAdResolvedProvider.notifier).state = true;
|
||||
unawaited(controller.refresh());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
|
||||
const splashAdPlacement = 'splash';
|
||||
const homeBannerAdPlacement = 'home_banner';
|
||||
|
||||
enum AdMediaType { image, gif, video, unsupported }
|
||||
|
||||
enum AdOpenMode { external, inApp }
|
||||
|
||||
class AdCreative {
|
||||
const AdCreative({
|
||||
required this.adId,
|
||||
required this.placement,
|
||||
required this.targetApp,
|
||||
required this.title,
|
||||
required this.imageUrl,
|
||||
required this.mediaType,
|
||||
required this.h5Url,
|
||||
required this.trackingId,
|
||||
required this.countdownSeconds,
|
||||
required this.endAtMs,
|
||||
required this.trackedUrl,
|
||||
required this.openMode,
|
||||
});
|
||||
final String adId,
|
||||
placement,
|
||||
targetApp,
|
||||
title,
|
||||
imageUrl,
|
||||
h5Url,
|
||||
trackingId,
|
||||
trackedUrl;
|
||||
final AdMediaType mediaType;
|
||||
final int countdownSeconds, endAtMs;
|
||||
final AdOpenMode openMode;
|
||||
Uri? get mediaUri => _http(imageUrl);
|
||||
Uri? get targetUri => _http(trackedUrl) ?? _http(h5Url);
|
||||
bool get isRenderable =>
|
||||
adId.isNotEmpty &&
|
||||
mediaUri != null &&
|
||||
targetUri != null &&
|
||||
mediaType != AdMediaType.unsupported &&
|
||||
(endAtMs <= 0 || endAtMs > DateTime.now().millisecondsSinceEpoch);
|
||||
Uri? clickUri(String clickId) {
|
||||
final uri = targetUri;
|
||||
if (uri == null) return null;
|
||||
return uri.replace(
|
||||
queryParameters: {
|
||||
...uri.queryParameters,
|
||||
'ad_id': adId,
|
||||
'placement': placement,
|
||||
'tracking_id': trackingId,
|
||||
'click_id': clickId,
|
||||
'target_app': targetApp,
|
||||
'hook_type': placement,
|
||||
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'adId': adId,
|
||||
'placement': placement,
|
||||
'targetApp': targetApp,
|
||||
'title': title,
|
||||
'imageUrl': imageUrl,
|
||||
'mediaType': mediaType.index,
|
||||
'h5Url': h5Url,
|
||||
'trackingId': trackingId,
|
||||
'countdownSeconds': countdownSeconds,
|
||||
'endAtMs': endAtMs,
|
||||
'trackedUrl': trackedUrl,
|
||||
'openMode': openMode.index,
|
||||
};
|
||||
factory AdCreative.fromJson(Map<String, dynamic> value) => AdCreative(
|
||||
adId: '${value['adId'] ?? ''}',
|
||||
placement: '${value['placement'] ?? ''}',
|
||||
targetApp: '${value['targetApp'] ?? ''}',
|
||||
title: '${value['title'] ?? ''}',
|
||||
imageUrl: '${value['imageUrl'] ?? ''}',
|
||||
mediaType:
|
||||
AdMediaType.values[(value['mediaType'] as num?)?.toInt().clamp(
|
||||
0,
|
||||
AdMediaType.values.length - 1,
|
||||
) ??
|
||||
AdMediaType.unsupported.index],
|
||||
h5Url: '${value['h5Url'] ?? ''}',
|
||||
trackingId: '${value['trackingId'] ?? ''}',
|
||||
countdownSeconds: (value['countdownSeconds'] as num?)?.toInt() ?? 0,
|
||||
endAtMs: (value['endAtMs'] as num?)?.toInt() ?? 0,
|
||||
trackedUrl: '${value['trackedUrl'] ?? ''}',
|
||||
openMode: (value['openMode'] as num?)?.toInt() == AdOpenMode.inApp.index
|
||||
? 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,
|
||||
_ => 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,
|
||||
);
|
||||
}
|
||||
|
||||
class GetAdResult {
|
||||
const GetAdResult(this.enabled, this.ad, this.dailyCap);
|
||||
final bool enabled;
|
||||
final AdCreative? ad;
|
||||
final int dailyCap;
|
||||
}
|
||||
|
||||
class FeaturedAdsState {
|
||||
const FeaturedAdsState(this.enabled, this.ads, {this.trackingId});
|
||||
final bool enabled;
|
||||
final List<AdCreative> ads;
|
||||
final String? trackingId;
|
||||
bool get hasAds => enabled && ads.isNotEmpty;
|
||||
}
|
||||
|
||||
class AdsApiClient {
|
||||
AdsApiClient(this._dio);
|
||||
final Dio _dio;
|
||||
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);
|
||||
return GetAdResult(
|
||||
root.integer(1) != 0,
|
||||
ad.isRenderable ? ad : null,
|
||||
root.integer(3),
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
.map(AdCreative.fromProto)
|
||||
.where((ad) => ad.isRenderable)
|
||||
.toList(growable: false);
|
||||
return FeaturedAdsState(
|
||||
root.integer(1) != 0,
|
||||
ads,
|
||||
trackingId: root.string(3).isEmpty ? null : root.string(3),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> report(
|
||||
AdCreative ad,
|
||||
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(),
|
||||
);
|
||||
String newClickId() => const Uuid().v4();
|
||||
}
|
||||
|
||||
final adsApiClientProvider = Provider(
|
||||
(ref) => AdsApiClient(ref.watch(dioProvider)),
|
||||
);
|
||||
final featuredAdsProvider = FutureProvider.autoDispose
|
||||
.family<FeaturedAdsState, String>(
|
||||
(ref, placement) => ref.watch(adsApiClientProvider).listAds(placement),
|
||||
);
|
||||
|
||||
Uri? _http(String raw) {
|
||||
final uri = Uri.tryParse(raw.trim());
|
||||
return uri != null &&
|
||||
uri.host.isNotEmpty &&
|
||||
(uri.scheme == 'https' || uri.scheme == 'http')
|
||||
? 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> ? String.fromCharCodes(value) : '';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../data/ads_api_client.dart';
|
||||
|
||||
/// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。
|
||||
class AdMedia extends StatefulWidget {
|
||||
const AdMedia({
|
||||
super.key,
|
||||
required this.ad,
|
||||
this.assetPath,
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
final AdCreative ad;
|
||||
final String? assetPath;
|
||||
final BoxFit fit;
|
||||
@override
|
||||
State<AdMedia> createState() => _AdMediaState();
|
||||
}
|
||||
|
||||
class _AdMediaState extends State<AdMedia> {
|
||||
VideoPlayerController? player;
|
||||
bool ready = false, failed = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AdMedia old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.ad.adId != widget.ad.adId || old.assetPath != widget.assetPath) {
|
||||
unawaited(player?.dispose() ?? Future.value());
|
||||
player = null;
|
||||
ready = false;
|
||||
failed = false;
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
void _load() {
|
||||
if (widget.ad.mediaType != AdMediaType.video) return;
|
||||
final uri = widget.ad.mediaUri;
|
||||
if (uri == null) return;
|
||||
final path = widget.assetPath;
|
||||
final controller = !kIsWeb && path != null && path.isNotEmpty
|
||||
? VideoPlayerController.file(File(path))
|
||||
: VideoPlayerController.networkUrl(uri);
|
||||
player = controller;
|
||||
unawaited(
|
||||
controller
|
||||
.initialize()
|
||||
.then((_) async {
|
||||
await controller.setLooping(true);
|
||||
await controller.setVolume(0);
|
||||
await controller.play();
|
||||
if (mounted) setState(() => ready = true);
|
||||
})
|
||||
.catchError((_) {
|
||||
if (mounted) setState(() => failed = true);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
player?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.ad.mediaType != AdMediaType.video) {
|
||||
final path = widget.assetPath;
|
||||
if (!kIsWeb && path != null && path.isNotEmpty) {
|
||||
return Image.file(
|
||||
File(path),
|
||||
fit: widget.fit,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, _, _) => _error(),
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
widget.ad.imageUrl,
|
||||
fit: widget.fit,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, _, _) => _error(),
|
||||
);
|
||||
}
|
||||
final c = player;
|
||||
if (failed) return _error();
|
||||
if (c == null || !ready) {
|
||||
return const ColoredBox(
|
||||
color: Color(0xFF161616),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return FittedBox(
|
||||
fit: widget.fit,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: c.value.size.width,
|
||||
height: c.value.size.height,
|
||||
child: VideoPlayer(c),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _error() => const ColoredBox(
|
||||
color: Color(0xFF161616),
|
||||
child: Center(
|
||||
child: Icon(Icons.broken_image_outlined, color: Colors.white54),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../data/ads_api_client.dart';
|
||||
import 'ad_media.dart';
|
||||
|
||||
class HomeAdBanner extends ConsumerStatefulWidget {
|
||||
const HomeAdBanner({super.key});
|
||||
@override
|
||||
ConsumerState<HomeAdBanner> createState() => _HomeAdBannerState();
|
||||
}
|
||||
|
||||
class _HomeAdBannerState extends ConsumerState<HomeAdBanner> {
|
||||
final pages = PageController();
|
||||
final impressed = <String>{};
|
||||
Timer? timer;
|
||||
int index = 0;
|
||||
int _length = 0;
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync(List<AdCreative> ads) {
|
||||
if (_length == ads.length) return;
|
||||
_length = ads.length;
|
||||
timer?.cancel();
|
||||
if (ads.length > 1) {
|
||||
timer = Timer.periodic(const Duration(seconds: 4), (_) {
|
||||
if (pages.hasClients) {
|
||||
pages.nextPage(
|
||||
duration: const Duration(milliseconds: 320),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _impression(AdCreative ad, String? id) {
|
||||
if (!impressed.add(ad.adId)) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(adsApiClientProvider)
|
||||
.report(ad, 'ad_impression', trackingId: id),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _open(AdCreative ad, String? tracking) async {
|
||||
final client = ref.read(adsApiClientProvider);
|
||||
final id = client.newClickId();
|
||||
final uri = ad.clickUri(id);
|
||||
if (uri == null) return;
|
||||
unawaited(client.report(ad, 'ad_click', clickId: id, trackingId: tracking));
|
||||
if (!mounted) return;
|
||||
if (ad.openMode == AdOpenMode.inApp) {
|
||||
context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(uri.toString())}',
|
||||
);
|
||||
} else {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final async = ref.watch(featuredAdsProvider(homeBannerAdPlacement));
|
||||
return async.maybeWhen(
|
||||
data: (value) {
|
||||
if (!value.hasAds) return const SizedBox.shrink();
|
||||
final ads = value.ads;
|
||||
_sync(ads);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_impression(ads[index.clamp(0, ads.length - 1)], value.trackingId);
|
||||
}
|
||||
});
|
||||
return Column(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 750 / 200,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: PageView.builder(
|
||||
controller: pages,
|
||||
onPageChanged: (page) {
|
||||
setState(() => index = page % ads.length);
|
||||
_impression(ads[index], value.trackingId);
|
||||
},
|
||||
itemBuilder: (_, page) {
|
||||
final ad = ads[page % ads.length];
|
||||
return GestureDetector(
|
||||
onTap: () => _open(ad, value.trackingId),
|
||||
child: AdMedia(ad: ad),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ads.length > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
ads.length,
|
||||
(i) => Container(
|
||||
width: i == index ? 14 : 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: i == index
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/splash_ad_controller.dart';
|
||||
import 'ad_media.dart';
|
||||
|
||||
enum SplashAdResult { clicked, skipped, completed }
|
||||
|
||||
class SplashAdDialog extends StatefulWidget {
|
||||
const SplashAdDialog({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.onClick,
|
||||
required this.onShown,
|
||||
});
|
||||
final SplashAdEntry entry;
|
||||
final Future<void> Function() onClick;
|
||||
final VoidCallback onShown;
|
||||
@override
|
||||
State<SplashAdDialog> createState() => _SplashAdDialogState();
|
||||
}
|
||||
|
||||
class _SplashAdDialogState extends State<SplashAdDialog> {
|
||||
Timer? timer;
|
||||
late int remain;
|
||||
bool opening = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
remain = widget.entry.ad.countdownSeconds.clamp(1, 5);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onShown();
|
||||
});
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (remain <= 1) {
|
||||
Navigator.of(context).pop(SplashAdResult.completed);
|
||||
} else if (mounted) {
|
||||
setState(() => remain--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> open() async {
|
||||
if (opening) return;
|
||||
opening = true;
|
||||
timer?.cancel();
|
||||
await widget.onClick();
|
||||
if (mounted) Navigator.of(context).pop(SplashAdResult.clicked);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: open,
|
||||
child: AdMedia(
|
||||
ad: widget.entry.ad,
|
||||
assetPath: widget.entry.assetPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 16,
|
||||
right: 18,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(SplashAdResult.skipped),
|
||||
child: Text('跳过 ${remain}s'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,80 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../common/domain/money.dart';
|
||||
import '../../../common/push/notification_permission.dart';
|
||||
import '../../../common/push/umeng_push_service.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../../ads/application/startup_ad_service.dart';
|
||||
import '../../ads/presentation/home_ad_banner.dart';
|
||||
import '../../settings/application/update_controller.dart';
|
||||
import '../../settings/application/update_service.dart';
|
||||
import '../application/asset_portfolio_controller.dart';
|
||||
|
||||
class AssetsPage extends ConsumerWidget {
|
||||
class AssetsPage extends ConsumerStatefulWidget {
|
||||
const AssetsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<AssetsPage> createState() => _AssetsPageState();
|
||||
}
|
||||
|
||||
class _AssetsPageState extends ConsumerState<AssetsPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_completeColdLaunch());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _completeColdLaunch() async {
|
||||
if (!mounted) return;
|
||||
await StartupAdService.showIfAvailable(context, ref);
|
||||
if (!mounted) return;
|
||||
await _checkColdLaunchUpgrade();
|
||||
if (mounted) await _requestNotificationPermission();
|
||||
}
|
||||
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
if (!mounted) return;
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
// 研听的 U-Push register 会请求 APNs;这里再以系统 API 兜底,避免
|
||||
// 旧安装已跳过欢迎页时永远不触发首次通知授权。iOS 不展示顶部说明。
|
||||
await NotificationPermission.requestFromSettings();
|
||||
} else {
|
||||
await NotificationPermission.requestIfNeeded(context);
|
||||
}
|
||||
await UmengPushService.refreshDeviceToken();
|
||||
}
|
||||
|
||||
Future<void> _checkColdLaunchUpgrade() async {
|
||||
if (!mounted ||
|
||||
!UpdateService.isSupported ||
|
||||
!ref.read(updateSessionGateProvider).takeColdLaunchSlot()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final upgrade = await ref
|
||||
.read(updateControllerProvider.notifier)
|
||||
.checkUpgrade();
|
||||
if (!mounted) return;
|
||||
await UpdateService.showIfNeeded(
|
||||
context,
|
||||
ref,
|
||||
upgrade,
|
||||
source: UpgradeCheckSource.automatic,
|
||||
);
|
||||
} catch (_) {
|
||||
// Automatic update checks must never block the assets home page.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final summary = ref.watch(assetPortfolioControllerProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return SafeArea(
|
||||
@@ -71,6 +135,8 @@ class AssetsPage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const HomeAdBanner(),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/auth_interceptor.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
import '../data/auth_models.dart';
|
||||
import '../data/one_key_login_api.dart';
|
||||
|
||||
final authProvider =
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||
(ref) => AuthController(),
|
||||
(ref) => AuthController(ref.read(dioProvider)),
|
||||
);
|
||||
|
||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
AuthController() : super(const AsyncLoading()) {
|
||||
AuthController(this._dio) : super(const AsyncLoading()) {
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final token = await loadJwt();
|
||||
if (token == null || token.isEmpty) {
|
||||
@@ -54,6 +59,24 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Test environment reuses Yanting's one-key verification endpoint until
|
||||
/// 金值 has its own account contract.
|
||||
Future<void> verifyOneKeyLogin(String loginToken) async {
|
||||
final reply = await OneKeyLoginApi(_dio).verify(loginToken);
|
||||
await saveJwt(reply.token);
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, reply.phone);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: reply.phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
token: reply.token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await clearJwt();
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/router/app_router.dart';
|
||||
import 'auth_controller.dart';
|
||||
import 'one_key_login_service.dart';
|
||||
|
||||
/// Shared login entry: launch the carrier-owned authorization page first and
|
||||
/// use the Flutter SMS page only as a deliberate fallback.
|
||||
abstract final class LoginLauncher {
|
||||
static final _service = OneKeyLoginService();
|
||||
static bool _starting = false;
|
||||
|
||||
static Future<void> start(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
String? next,
|
||||
}) async {
|
||||
if (_starting) return;
|
||||
_starting = true;
|
||||
final dismissLoading = _showLoading(context);
|
||||
try {
|
||||
final observer = _OneKeyLoginLifecycleObserver(_service);
|
||||
WidgetsBinding.instance.addObserver(observer);
|
||||
late final OneKeyLoginOutcome outcome;
|
||||
try {
|
||||
outcome = await _service.start(
|
||||
context: context,
|
||||
verifyToken: ref.read(authProvider.notifier).verifyOneKeyLogin,
|
||||
onNativeRequestDispatched: dismissLoading,
|
||||
onAuthPageOpened: dismissLoading,
|
||||
);
|
||||
} catch (_) {
|
||||
outcome = OneKeyLoginOutcome.fallback;
|
||||
} finally {
|
||||
WidgetsBinding.instance.removeObserver(observer);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
if (outcome == OneKeyLoginOutcome.success) {
|
||||
context.go(next?.isNotEmpty == true ? next! : AppRoutes.profile);
|
||||
return;
|
||||
}
|
||||
if (outcome == OneKeyLoginOutcome.fallback ||
|
||||
outcome == OneKeyLoginOutcome.unsupported) {
|
||||
final target = next == null || next.isEmpty
|
||||
? AppRoutes.login
|
||||
: '${AppRoutes.login}?next=${Uri.encodeComponent(next)}';
|
||||
if (GoRouterState.of(context).matchedLocation != AppRoutes.login) {
|
||||
await context.push(target);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
dismissLoading();
|
||||
_starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static VoidCallback _showLoading(BuildContext context) {
|
||||
var visible = true;
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const PopScope(
|
||||
canPop: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
);
|
||||
return () {
|
||||
if (visible && context.mounted) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
visible = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _OneKeyLoginLifecycleObserver with WidgetsBindingObserver {
|
||||
_OneKeyLoginLifecycleObserver(this._service);
|
||||
final OneKeyLoginService _service;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) _service.onAppResumed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:umeng_verify_sdk/umeng_verify_sdk.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/tracking/umeng_service.dart';
|
||||
|
||||
enum OneKeyLoginOutcome { success, fallback, cancelled, unsupported, busy }
|
||||
|
||||
/// Runs only after an explicit user login action. Do not add an availability
|
||||
/// probe or `accelerateLoginPage` outside this class: Xiaomi rejects that as
|
||||
/// background carrier-auth prefetch.
|
||||
class OneKeyLoginService {
|
||||
Future<OneKeyLoginOutcome>? _operation;
|
||||
bool _authPageOpened = false;
|
||||
bool _terminal = false;
|
||||
Timer? _resumeCancellation;
|
||||
void Function(OneKeyLoginOutcome)? _completeCurrent;
|
||||
|
||||
Future<OneKeyLoginOutcome> start({
|
||||
required BuildContext context,
|
||||
required Future<void> Function(String token) verifyToken,
|
||||
VoidCallback? onNativeRequestDispatched,
|
||||
VoidCallback? onAuthPageOpened,
|
||||
}) {
|
||||
if (_operation != null) return Future.value(OneKeyLoginOutcome.busy);
|
||||
_terminal = false;
|
||||
_authPageOpened = false;
|
||||
final operation = _start(
|
||||
context: context,
|
||||
verifyToken: verifyToken,
|
||||
onNativeRequestDispatched: onNativeRequestDispatched,
|
||||
onAuthPageOpened: onAuthPageOpened,
|
||||
);
|
||||
_operation = operation;
|
||||
return operation.whenComplete(() {
|
||||
_operation = null;
|
||||
_completeCurrent = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<OneKeyLoginOutcome> _start({
|
||||
required BuildContext context,
|
||||
required Future<void> Function(String token) verifyToken,
|
||||
VoidCallback? onNativeRequestDispatched,
|
||||
VoidCallback? onAuthPageOpened,
|
||||
}) async {
|
||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
|
||||
return OneKeyLoginOutcome.unsupported;
|
||||
}
|
||||
final uiConfig = _uiConfig(context);
|
||||
await UmengService.initOneKeyLoginIfAllowed();
|
||||
final completer = Completer<OneKeyLoginOutcome>();
|
||||
|
||||
void complete(OneKeyLoginOutcome outcome) {
|
||||
if (_terminal) return;
|
||||
_terminal = true;
|
||||
_resumeCancellation?.cancel();
|
||||
_resumeCancellation = null;
|
||||
_clearCallbacks();
|
||||
completer.complete(outcome);
|
||||
}
|
||||
|
||||
_completeCurrent = complete;
|
||||
|
||||
void handleUiResult(Object? result) {
|
||||
final code = _code(result);
|
||||
if (code == '700000') complete(OneKeyLoginOutcome.cancelled);
|
||||
if (code == '700001') complete(OneKeyLoginOutcome.fallback);
|
||||
}
|
||||
|
||||
void handleTokenResult(Object? result) {
|
||||
final code = _code(result);
|
||||
// iOS 700002 is only the native button click; token follows later.
|
||||
if (Platform.isIOS && code == '700002') return;
|
||||
// 600024 is Android environment availability, not a login result.
|
||||
if (code == '600024') return;
|
||||
if (code == '600001') {
|
||||
if (!_authPageOpened) {
|
||||
_authPageOpened = true;
|
||||
onAuthPageOpened?.call();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (code == '700000') {
|
||||
complete(OneKeyLoginOutcome.cancelled);
|
||||
return;
|
||||
}
|
||||
if (code == '700001') {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
return;
|
||||
}
|
||||
if (code != '600000' || result is! Map) {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
return;
|
||||
}
|
||||
final token = result['token']?.toString();
|
||||
if (token == null || token.isEmpty) {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
return;
|
||||
}
|
||||
unawaited(() async {
|
||||
try {
|
||||
await verifyToken(token);
|
||||
complete(OneKeyLoginOutcome.success);
|
||||
} catch (_) {
|
||||
complete(OneKeyLoginOutcome.fallback);
|
||||
} finally {
|
||||
_closeNativePage();
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
UmengVerifySdk.getLoginTokenCallback(handleTokenResult);
|
||||
UmengVerifySdk.getWidgetEventCallback((_) {});
|
||||
} else {
|
||||
UmengVerifySdk.setTokenResultCallback_android(handleTokenResult);
|
||||
UmengVerifySdk.setUIClickCallback_android(handleUiResult);
|
||||
}
|
||||
try {
|
||||
final request = UmengVerifySdk.getLoginTokenWithTimeout(3, uiConfig);
|
||||
onNativeRequestDispatched?.call();
|
||||
unawaited(
|
||||
request.catchError((Object _) {
|
||||
complete(OneKeyLoginOutcome.unsupported);
|
||||
}),
|
||||
);
|
||||
return await completer.future.timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => OneKeyLoginOutcome.fallback,
|
||||
);
|
||||
} finally {
|
||||
_clearCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
void onAppResumed() {
|
||||
if (!Platform.isAndroid ||
|
||||
!_authPageOpened ||
|
||||
_terminal ||
|
||||
_resumeCancellation != null) {
|
||||
return;
|
||||
}
|
||||
_resumeCancellation = Timer(const Duration(milliseconds: 500), () {
|
||||
_resumeCancellation = null;
|
||||
if (_terminal) return;
|
||||
_completeCurrent?.call(OneKeyLoginOutcome.cancelled);
|
||||
_closeNativePage();
|
||||
});
|
||||
}
|
||||
|
||||
UMCustomModel _uiConfig(BuildContext context) {
|
||||
const ink = Color(0xFF202124);
|
||||
const muted = Color(0xFF8A8F98);
|
||||
const background = Color(0xFFFFFFFF);
|
||||
final model = UMCustomModel()
|
||||
..logoIsHidden = true
|
||||
..numberColor = ink.toARGB32()
|
||||
..numberFont = 30
|
||||
..navTitle = ['', Colors.transparent.toARGB32(), 0]
|
||||
..navColor = background.toARGB32()
|
||||
..backgroundColor_ios = background.toARGB32()
|
||||
..sloganText = ['', background.toARGB32(), 0]
|
||||
..privacyPreText = '我已阅读并同意'
|
||||
..privacyOne = ['《隐私政策》', AppH5Urls.privacyUrl]
|
||||
..privacyTwo = ['《用户协议》', AppH5Urls.userProtocolUrl]
|
||||
..privacyColors = [muted.toARGB32(), ink.toARGB32()]
|
||||
..changeBtnTitle = ['其他手机号登录', ink.toARGB32(), 16]
|
||||
..loginBtnText = ['本机号码一键登录', Colors.white.toARGB32(), 16]
|
||||
..privacyFont = 12
|
||||
..checkBoxIsChecked = false;
|
||||
if (Platform.isAndroid) {
|
||||
model.numberFrame = [-1, 156, -1, -1];
|
||||
model.loginBtnFrame = [-1, 283, -1, -1, 52];
|
||||
model.changeBtnFrame = [368, -1, -1, -1];
|
||||
model.privacyFrame = [-1, 427, -1, -1];
|
||||
} else {
|
||||
final title = UMCustomWidget('title', UMCustomWidgetType.textView)
|
||||
..title = '金值'
|
||||
..titleFont = 26
|
||||
..titleColor = ink.toARGB32()
|
||||
..width = MediaQuery.sizeOf(context).width.toInt()
|
||||
..height = 54
|
||||
..top = 26
|
||||
..textAlignment = UMCustomWidgetTextAlignmentType.center
|
||||
..isClickEnable = false
|
||||
..rootViewId = UMRootViewId.body;
|
||||
model.customWidget = [title.toJsonMap()];
|
||||
model.privacyFont = 13;
|
||||
model.checkBoxWH = 20;
|
||||
model.checkBoxIsChecked = true;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
String _code(Object? result) => result is Map
|
||||
? (result['code'] ?? result['resultCode'] ?? '').toString()
|
||||
: '';
|
||||
|
||||
void _clearCallbacks() {
|
||||
UmengVerifySdk.getLoginTokenCallback(null);
|
||||
UmengVerifySdk.getWidgetEventCallback(null);
|
||||
UmengVerifySdk.setTokenResultCallback_android(null);
|
||||
UmengVerifySdk.setUIClickCallback_android(null);
|
||||
}
|
||||
|
||||
void _closeNativePage() {
|
||||
if (Platform.isIOS) {
|
||||
unawaited(UmengVerifySdk.cancelLoginVCAnimated(true));
|
||||
} else {
|
||||
UmengVerifySdk.quitLoginPage_android();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
|
||||
/// Compatible with Yanting's protobuf `/api/v1/auth/onekey/verify` contract.
|
||||
class OneKeyLoginApi {
|
||||
OneKeyLoginApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<OneKeyLoginReply> verify(String loginToken) async {
|
||||
final bytes = utf8.encode(loginToken);
|
||||
final request = Uint8List.fromList([
|
||||
0x0A,
|
||||
..._varint(bytes.length),
|
||||
...bytes,
|
||||
]);
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/api/v1/auth/onekey/verify',
|
||||
data: request,
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return _decode(Uint8List.fromList(response.data ?? const []));
|
||||
}
|
||||
|
||||
OneKeyLoginReply _decode(Uint8List data) {
|
||||
final fields = _fields(data);
|
||||
final token = utf8.decode(fields[1] ?? const []);
|
||||
final user = _fields(Uint8List.fromList(fields[3] ?? const []));
|
||||
if (token.isEmpty) throw StateError('一键登录服务未返回登录凭证');
|
||||
return OneKeyLoginReply(
|
||||
token: token,
|
||||
phone: utf8.decode(user[2] ?? const []),
|
||||
);
|
||||
}
|
||||
|
||||
Map<int, List<int>> _fields(Uint8List data) {
|
||||
final result = <int, List<int>>{};
|
||||
var i = 0;
|
||||
while (i < data.length) {
|
||||
final tag = _readVarint(data, i);
|
||||
i = tag.$2;
|
||||
final wireType = tag.$1 & 7;
|
||||
if (wireType == 0) {
|
||||
i = _readVarint(data, i).$2;
|
||||
continue;
|
||||
}
|
||||
if (wireType != 2) {
|
||||
throw const FormatException('Unsupported protobuf field');
|
||||
}
|
||||
final length = _readVarint(data, i);
|
||||
i = length.$2;
|
||||
final end = i + length.$1;
|
||||
if (end > data.length) {
|
||||
throw const FormatException('Invalid protobuf payload');
|
||||
}
|
||||
result[tag.$1 >> 3] = data.sublist(i, end);
|
||||
i = end;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
(int, int) _readVarint(Uint8List data, int index) {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < data.length) {
|
||||
final byte = data[index++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (byte & 0x80 == 0) return (value, index);
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf varint');
|
||||
}
|
||||
|
||||
List<int> _varint(int value) {
|
||||
final result = <int>[];
|
||||
while (value > 0x7f) {
|
||||
result.add((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
result.add(value);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class OneKeyLoginReply {
|
||||
const OneKeyLoginReply({required this.token, required this.phone});
|
||||
final String token;
|
||||
final String phone;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import '../../../common/router/app_router.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/widgets/circle_back_button.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../application/login_launcher.dart';
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
const LoginPage({super.key, this.next});
|
||||
@@ -31,6 +32,7 @@ class LoginPage extends HookConsumerWidget {
|
||||
final agreed = useState(false);
|
||||
final sending = useState(false);
|
||||
final verifying = useState(false);
|
||||
final oneKeyLoading = useState(false);
|
||||
final countdown = useState(0);
|
||||
final error = useState<String?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
@@ -163,6 +165,9 @@ class LoginPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> launchOneKey() =>
|
||||
LoginLauncher.start(context, ref, next: next);
|
||||
|
||||
void onSendTap() {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (phoneDigits().length != 11) {
|
||||
@@ -222,6 +227,8 @@ class LoginPage extends HookConsumerWidget {
|
||||
agreed: agreed.value,
|
||||
onToggleAgreed: () => agreed.value = !agreed.value,
|
||||
onSend: onSendTap,
|
||||
oneKeyLoading: oneKeyLoading.value,
|
||||
onOneKey: launchOneKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -240,6 +247,8 @@ class _PhoneStep extends StatelessWidget {
|
||||
required this.onToggleAgreed,
|
||||
required this.error,
|
||||
required this.onSend,
|
||||
required this.oneKeyLoading,
|
||||
required this.onOneKey,
|
||||
});
|
||||
|
||||
final ShadColorScheme cs;
|
||||
@@ -251,6 +260,8 @@ class _PhoneStep extends StatelessWidget {
|
||||
final VoidCallback onToggleAgreed;
|
||||
final String? error;
|
||||
final VoidCallback onSend;
|
||||
final bool oneKeyLoading;
|
||||
final VoidCallback onOneKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -295,6 +306,13 @@ class _PhoneStep extends StatelessWidget {
|
||||
dim: !phoneReady,
|
||||
onTap: onSend,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PrimaryButton(
|
||||
label: '本机号码一键登录',
|
||||
enabled: !oneKeyLoading,
|
||||
loading: oneKeyLoading,
|
||||
onTap: onOneKey,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
|
||||
/// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。
|
||||
const mallSourceApp = 'jinzhi';
|
||||
|
||||
class MallTabConfig {
|
||||
const MallTabConfig({
|
||||
required this.enabled,
|
||||
required this.url,
|
||||
this.title = '商城',
|
||||
this.adId = '',
|
||||
this.trackingId = '',
|
||||
this.openMode = 'in_app',
|
||||
this.badgeText = '',
|
||||
});
|
||||
|
||||
const MallTabConfig.disabled() : this(enabled: false, url: '');
|
||||
|
||||
final bool enabled;
|
||||
final String url;
|
||||
final String title;
|
||||
final String adId;
|
||||
final String trackingId;
|
||||
final String openMode;
|
||||
final String badgeText;
|
||||
|
||||
bool get isVisible => enabled && initialUri != null;
|
||||
bool get opensInApp => openMode == 'in_app';
|
||||
|
||||
Uri? get initialUri => attributedUri(clickId: '');
|
||||
|
||||
Uri? attributedUri({required String clickId, bool isDark = false}) {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (!_isAllowed(uri)) return null;
|
||||
return uri!.replace(
|
||||
queryParameters: {
|
||||
...uri.queryParameters,
|
||||
'from': uri.queryParameters['from']?.trim().isNotEmpty == true
|
||||
? uri.queryParameters['from']!
|
||||
: mallSourceApp,
|
||||
'source_app': mallSourceApp,
|
||||
'source_entry': 'mall_tab',
|
||||
'placement': 'mall_tab',
|
||||
'hook_type': 'mall_tab',
|
||||
'target_app': 'mall',
|
||||
if (adId.isNotEmpty) 'ad_id': adId,
|
||||
if (trackingId.isNotEmpty) 'tracking_id': trackingId,
|
||||
if (clickId.isNotEmpty) 'click_id': clickId,
|
||||
if (clickId.isNotEmpty)
|
||||
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'isNight': isDark ? '1' : '0',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isAllowed(Uri? uri) {
|
||||
if (uri == null || uri.host.isEmpty) return false;
|
||||
if (uri.scheme == 'https') return true;
|
||||
return !kReleaseMode &&
|
||||
uri.scheme == 'http' &&
|
||||
const {'localhost', '127.0.0.1', '::1'}.contains(uri.host);
|
||||
}
|
||||
}
|
||||
|
||||
class MallTabConfigController extends StateNotifier<MallTabConfig> {
|
||||
MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
final Dio _dio;
|
||||
Future<void>? _loading;
|
||||
|
||||
Future<MallTabConfig> ensureLoaded() async {
|
||||
if (state.isVisible) return state;
|
||||
await (_loading ??= refresh().whenComplete(() => _loading = null));
|
||||
return state;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final response = await _dio.get<List<int>>(
|
||||
'/api/v1/ads',
|
||||
queryParameters: const {'placement': 'mall_tab'},
|
||||
options: protobufOptions(),
|
||||
);
|
||||
final reply = _ProtoMessage(response.data ?? const []).message(2);
|
||||
final enabled = _ProtoMessage(response.data ?? const []).integer(1) != 0;
|
||||
final trackedUrl = reply.string(14);
|
||||
final h5Url = reply.string(9);
|
||||
final url = _validUrl(trackedUrl) ?? _validUrl(h5Url);
|
||||
if (!enabled || url == null) {
|
||||
state = const MallTabConfig.disabled();
|
||||
return;
|
||||
}
|
||||
state = MallTabConfig(
|
||||
enabled: true,
|
||||
url: url,
|
||||
title: _nonEmpty(reply.string(4), '商城'),
|
||||
adId: reply.string(1),
|
||||
trackingId: reply.string(10),
|
||||
openMode: _openMode(reply.integer(15)),
|
||||
badgeText: reply.string(17).trim(),
|
||||
);
|
||||
await _report('ad_impression');
|
||||
} catch (_) {
|
||||
state = const MallTabConfig.disabled();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> recordClick() async {
|
||||
if (!state.isVisible) return null;
|
||||
final clickId = const Uuid().v4();
|
||||
try {
|
||||
await _report('ad_click', clickId: clickId);
|
||||
} catch (_) {
|
||||
// 归因上报失败不能阻断商城入口。
|
||||
}
|
||||
return clickId;
|
||||
}
|
||||
|
||||
Future<void> _report(String event, {String clickId = ''}) =>
|
||||
_dio.post<List<int>>(
|
||||
'/api/v1/ads/events',
|
||||
data: _ProtoWriter()
|
||||
.string(1, event)
|
||||
.string(2, 'mall_tab')
|
||||
.string(3, state.adId)
|
||||
.string(4, state.trackingId)
|
||||
.string(5, clickId)
|
||||
.integer(6, DateTime.now().millisecondsSinceEpoch)
|
||||
.toBytes(),
|
||||
options: protobufOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
final mallTabConfigProvider =
|
||||
StateNotifierProvider<MallTabConfigController, MallTabConfig>(
|
||||
(ref) => MallTabConfigController(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
String _nonEmpty(String value, String fallback) =>
|
||||
value.trim().isEmpty ? fallback : value.trim();
|
||||
|
||||
String? _validUrl(String raw) {
|
||||
final uri = Uri.tryParse(raw.trim());
|
||||
return MallTabConfig._isAllowed(uri) ? uri.toString() : null;
|
||||
}
|
||||
|
||||
String _openMode(int value) => switch (value) {
|
||||
1 => 'native_app',
|
||||
2 => 'in_app',
|
||||
_ => 'external',
|
||||
};
|
||||
|
||||
class _ProtoMessage {
|
||||
_ProtoMessage(this._bytes);
|
||||
final List<int> _bytes;
|
||||
|
||||
int integer(int wanted) =>
|
||||
_fields()[wanted] is int ? _fields()[wanted]! as int : 0;
|
||||
String string(int wanted) => _fields()[wanted] is List<int>
|
||||
? String.fromCharCodes(_fields()[wanted]! as List<int>)
|
||||
: '';
|
||||
_ProtoMessage message(int wanted) => _ProtoMessage(
|
||||
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
|
||||
);
|
||||
|
||||
Map<int, Object> _fields() {
|
||||
final values = <int, Object>{};
|
||||
var index = 0;
|
||||
int readVarint() {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (index < _bytes.length && shift < 64) {
|
||||
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 = readVarint();
|
||||
final tag = key >> 3;
|
||||
switch (key & 7) {
|
||||
case 0:
|
||||
values[tag] = readVarint();
|
||||
case 2:
|
||||
final length = readVarint();
|
||||
final end = index + length;
|
||||
if (end > _bytes.length) {
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
values[tag] = _bytes.sublist(index, end);
|
||||
index = end;
|
||||
default:
|
||||
throw const FormatException('Unsupported protobuf');
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
class _ProtoWriter {
|
||||
final _bytes = BytesBuilder(copy: false);
|
||||
_ProtoWriter string(int tag, String value) {
|
||||
final data = value.codeUnits;
|
||||
_varint((tag << 3) | 2);
|
||||
_varint(data.length);
|
||||
_bytes.add(data);
|
||||
return this;
|
||||
}
|
||||
|
||||
_ProtoWriter integer(int tag, int value) {
|
||||
_varint(tag << 3);
|
||||
_varint(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
Uint8List toBytes() => _bytes.toBytes();
|
||||
void _varint(int value) {
|
||||
while (value > 127) {
|
||||
_bytes.addByte((value & 127) | 128);
|
||||
value >>= 7;
|
||||
}
|
||||
_bytes.addByte(value);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/application/login_launcher.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../profile/application/profile_settings_controller.dart';
|
||||
|
||||
@@ -58,10 +59,6 @@ class ProfilePage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => context.push(AppRoutes.settings),
|
||||
child: const Text('设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -103,9 +100,7 @@ class ProfilePage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
onPressed: loggedIn
|
||||
? () => context.push(AppRoutes.login)
|
||||
: () => context.push(AppRoutes.login),
|
||||
onPressed: () => LoginLauncher.start(context, ref),
|
||||
child: Text(loggedIn ? '切换账号' : '去登录'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../data/update_api_client.dart';
|
||||
|
||||
class UpdateSessionGate {
|
||||
var _used = false;
|
||||
bool takeColdLaunchSlot() => _used ? false : (_used = true);
|
||||
}
|
||||
|
||||
final updateSessionGateProvider = Provider<UpdateSessionGate>(
|
||||
(_) => UpdateSessionGate(),
|
||||
);
|
||||
|
||||
class UpdateController extends StateNotifier<AsyncValue<UpgradeInfo?>> {
|
||||
UpdateController(this._read) : super(const AsyncData(null));
|
||||
final T Function<T>(ProviderListenable<T>) _read;
|
||||
|
||||
Future<UpgradeInfo> checkUpgrade() async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final result = await _read(updateApiClientProvider).checkUpgrade();
|
||||
state = AsyncData(result);
|
||||
return result;
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncError(error, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final updateControllerProvider =
|
||||
StateNotifierProvider<UpdateController, AsyncValue<UpgradeInfo?>>(
|
||||
(ref) => UpdateController(ref.read),
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'update_controller.dart';
|
||||
import 'update_service.dart';
|
||||
|
||||
/// 前后台切换仅低频检查一次,网络失败或无新版本均静默结束。
|
||||
abstract final class UpdateForegroundService {
|
||||
static DateTime? _lastCheck;
|
||||
static Future<void>? _running;
|
||||
static Future<void> check(BuildContext context, WidgetRef ref) {
|
||||
final now = DateTime.now();
|
||||
if (_lastCheck != null &&
|
||||
now.difference(_lastCheck!) < const Duration(minutes: 30)) {
|
||||
return Future.value();
|
||||
}
|
||||
return _running ??= _run(context, ref).whenComplete(() => _running = null);
|
||||
}
|
||||
|
||||
static Future<void> _run(BuildContext context, WidgetRef ref) async {
|
||||
_lastCheck = DateTime.now();
|
||||
try {
|
||||
final info = await ref
|
||||
.read(updateControllerProvider.notifier)
|
||||
.checkUpgrade();
|
||||
if (context.mounted) {
|
||||
await UpdateService.showIfNeeded(
|
||||
context,
|
||||
ref,
|
||||
info,
|
||||
source: UpgradeCheckSource.automatic,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_app_update/flutter_app_update.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../data/update_api_client.dart';
|
||||
|
||||
enum UpgradeCheckSource { automatic, manual }
|
||||
|
||||
class UpdateService {
|
||||
static bool get isSupported =>
|
||||
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
static Future<void> showIfNeeded(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
UpgradeInfo info, {
|
||||
required UpgradeCheckSource source,
|
||||
VoidCallback? onLatest,
|
||||
}) async {
|
||||
final shouldShow = source == UpgradeCheckSource.automatic
|
||||
? info.show == 1 && info.hasPublishedUpdate
|
||||
: info.hasPublishedUpdate;
|
||||
if (!shouldShow) {
|
||||
onLatest?.call();
|
||||
return;
|
||||
}
|
||||
final uri = Uri.tryParse(info.url);
|
||||
if (uri == null || uri.scheme != 'https' || !uri.hasAuthority) return;
|
||||
final force = info.force == 1;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: !force,
|
||||
builder: (dialogContext) => PopScope(
|
||||
canPop: !force,
|
||||
child: AlertDialog(
|
||||
title: Text(
|
||||
'发现新版本${info.latestVersion.isEmpty ? '' : ' ${info.latestVersion}'}',
|
||||
),
|
||||
content: Text(info.message.isEmpty ? '发现新版本,建议立即更新。' : info.message),
|
||||
actions: [
|
||||
if (!force)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
final package = await PackageInfo.fromPlatform();
|
||||
try {
|
||||
await ref
|
||||
.read(updateApiClientProvider)
|
||||
.reportClick(
|
||||
buildId: info.versionCode.isEmpty
|
||||
? package.buildNumber
|
||||
: info.versionCode,
|
||||
source: source == UpgradeCheckSource.manual
|
||||
? 'manual'
|
||||
: 'auto',
|
||||
);
|
||||
} catch (_) {}
|
||||
await _startUpdate(uri);
|
||||
},
|
||||
child: Text(Platform.isIOS ? '前往更新' : '下载更新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _startUpdate(Uri uri) async {
|
||||
if (Platform.isIOS) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return;
|
||||
}
|
||||
await AzhonAppUpdate.update(
|
||||
UpdateModel(
|
||||
uri.toString(),
|
||||
'jinzhi-update.apk',
|
||||
'ic_launcher',
|
||||
'https://apps.apple.com/search?term=%E9%87%91%E5%80%BC',
|
||||
showNotification: true,
|
||||
showBgdToast: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/dio_provider.dart';
|
||||
|
||||
/// 金值 `jinzhi.v1.UpgradeReply` 的 HTTP protobuf 契约。
|
||||
class UpgradeInfo {
|
||||
const UpgradeInfo({
|
||||
required this.show,
|
||||
required this.force,
|
||||
required this.message,
|
||||
required this.url,
|
||||
required this.isLatestVersion,
|
||||
required this.latestVersion,
|
||||
required this.versionCode,
|
||||
});
|
||||
|
||||
final int show;
|
||||
final int force;
|
||||
final String message;
|
||||
final String url;
|
||||
final int isLatestVersion;
|
||||
final String latestVersion;
|
||||
final String versionCode;
|
||||
|
||||
bool get hasPublishedUpdate =>
|
||||
isLatestVersion != 1 &&
|
||||
url.trim().isNotEmpty &&
|
||||
(latestVersion.trim().isNotEmpty || versionCode.trim().isNotEmpty);
|
||||
|
||||
factory UpgradeInfo.fromProtobuf(List<int> bytes) {
|
||||
final fields = _ProtoReader(bytes).readFields();
|
||||
return UpgradeInfo(
|
||||
show: fields.integer(1),
|
||||
force: fields.integer(2),
|
||||
message: fields.string(3),
|
||||
url: fields.string(4),
|
||||
isLatestVersion: fields.integer(5),
|
||||
latestVersion: fields.string(6),
|
||||
versionCode: fields.string(7),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateApiClient {
|
||||
UpdateApiClient(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<UpgradeInfo> checkUpgrade() async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/sys/upgrade',
|
||||
data: const <int>[], // jinzhi.v1.GetUpgradeRequest
|
||||
options: protobufOptions(),
|
||||
);
|
||||
return UpgradeInfo.fromProtobuf(response.data ?? const <int>[]);
|
||||
}
|
||||
|
||||
Future<void> reportClick({
|
||||
required String buildId,
|
||||
required String source,
|
||||
}) async {
|
||||
await _dio.post<List<int>>(
|
||||
'/active/upgrade/click',
|
||||
data: _ProtoWriter()
|
||||
.string(1, buildId)
|
||||
.string(2, source)
|
||||
.toBytes(), // jinzhi.v1.UpgradeClickRequest
|
||||
options: protobufOptions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final updateApiClientProvider = Provider<UpdateApiClient>(
|
||||
(ref) => UpdateApiClient(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
class _ProtoFields {
|
||||
final _values = <int, Object>{};
|
||||
int integer(int tag) => _values[tag] as int? ?? 0;
|
||||
String string(int tag) => _values[tag] as String? ?? '';
|
||||
}
|
||||
|
||||
class _ProtoReader {
|
||||
_ProtoReader(this._bytes);
|
||||
final List<int> _bytes;
|
||||
var _index = 0;
|
||||
|
||||
_ProtoFields readFields() {
|
||||
final result = _ProtoFields();
|
||||
while (_index < _bytes.length) {
|
||||
final key = _varint();
|
||||
final tag = key >> 3;
|
||||
switch (key & 7) {
|
||||
case 0:
|
||||
result._values[tag] = _varint();
|
||||
case 2:
|
||||
final length = _varint();
|
||||
final end = _index + length;
|
||||
if (end > _bytes.length) {
|
||||
throw const FormatException('Invalid protobuf');
|
||||
}
|
||||
result._values[tag] = String.fromCharCodes(
|
||||
_bytes.sublist(_index, end),
|
||||
);
|
||||
_index = end;
|
||||
default:
|
||||
throw const FormatException('Unsupported protobuf wire type');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int _varint() {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
while (_index < _bytes.length && shift < 64) {
|
||||
final byte = _bytes[_index++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if (byte < 0x80) return value;
|
||||
shift += 7;
|
||||
}
|
||||
throw const FormatException('Invalid protobuf varint');
|
||||
}
|
||||
}
|
||||
|
||||
class _ProtoWriter {
|
||||
final _bytes = BytesBuilder(copy: false);
|
||||
_ProtoWriter string(int tag, String value) {
|
||||
final bytes = value.codeUnits;
|
||||
_varint((tag << 3) | 2);
|
||||
_varint(bytes.length);
|
||||
_bytes.add(bytes);
|
||||
return this;
|
||||
}
|
||||
|
||||
Uint8List toBytes() => _bytes.toBytes();
|
||||
void _varint(int value) {
|
||||
while (value > 0x7f) {
|
||||
_bytes.addByte((value & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
_bytes.addByte(value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -8,9 +10,13 @@ import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/services/device_header_service.dart';
|
||||
import '../../../common/push/umeng_push_service.dart';
|
||||
import '../../../common/push/notification_permission.dart';
|
||||
import '../../../common/privacy/umeng_privacy_consent.dart';
|
||||
import '../../../common/theme/jinzhi_theme.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/tracking/umeng_service.dart';
|
||||
|
||||
class PrivacyConsentPage extends StatefulWidget {
|
||||
const PrivacyConsentPage({super.key});
|
||||
@@ -47,12 +53,46 @@ class _PrivacyConsentPageState extends State<PrivacyConsentPage> {
|
||||
Future<void> _agree() async {
|
||||
await DeviceHeaderService.to.markPrivacyAgreed();
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
if (mounted) context.go(AppRoutes.assets);
|
||||
if (mounted) {
|
||||
context.go(AppRoutes.assets);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_completePostConsentInitialization();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _completePostConsentInitialization() async {
|
||||
// Consent navigation must never depend on an optional native SDK. The
|
||||
// calls below still run in order, but failures are retried by normal app
|
||||
// startup instead of trapping the user on the consent page.
|
||||
try {
|
||||
await UmengPrivacyConsent.submitGranted();
|
||||
await UmengService.initIfAllowed();
|
||||
await UmengPushService.startIfAllowed();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _enter() async {
|
||||
await DeviceHeaderService.to.markIosStartupSeen();
|
||||
if (mounted) context.go(AppRoutes.assets);
|
||||
if (!mounted) return;
|
||||
// 首屏跳转与可选 SDK 调用严格解耦。原生初始化、ATT 或网络问题都不得
|
||||
// 阻断“立即进入”。
|
||||
context.go(AppRoutes.assets);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_completeIosPostEnterInitialization());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _completeIosPostEnterInitialization() async {
|
||||
// iOS 不展示 Android 顶部说明;这里在可见首页后直接请求系统通知授权。
|
||||
// U-Push 注册保留,供 APNs token/友盟通道建立使用。
|
||||
try {
|
||||
await NotificationPermission.requestFromSettings();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await UmengService.requestTrackingAuthorizationIfNeeded();
|
||||
await UmengPushService.startIfAllowed();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_remix/flutter_remix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/push/notification_permission.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/services/cache_clear_service.dart';
|
||||
import '../../../common/theme/app_text.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/theme/theme_controller.dart';
|
||||
@@ -20,6 +22,9 @@ import '../../auth/data/auth_models.dart';
|
||||
import '../../profile/application/profile_settings_controller.dart';
|
||||
import '../../profile/data/profile_models.dart';
|
||||
import '../application/feedback_email_service.dart';
|
||||
import '../application/update_controller.dart';
|
||||
import '../application/update_service.dart';
|
||||
import '../data/update_api_client.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
@@ -31,6 +36,25 @@ class SettingsPage extends HookConsumerWidget {
|
||||
final profileSettings = ref.watch(profileSettingsControllerProvider);
|
||||
final authAsync = ref.watch(authProvider);
|
||||
final loggedIn = authAsync.valueOrNull is LoggedInAuthState;
|
||||
final updateSupported = UpdateService.isSupported;
|
||||
final upgrade = ref.watch(updateControllerProvider).valueOrNull;
|
||||
final notificationEnabled = useState(false);
|
||||
useEffect(() {
|
||||
if (!updateSupported) return null;
|
||||
Future<void>(() async {
|
||||
try {
|
||||
await ref.read(updateControllerProvider.notifier).checkUpgrade();
|
||||
} catch (_) {}
|
||||
});
|
||||
return null;
|
||||
}, [updateSupported]);
|
||||
useEffect(() {
|
||||
Future<void>(
|
||||
() async => notificationEnabled.value =
|
||||
await NotificationPermission.isGranted(),
|
||||
);
|
||||
return null;
|
||||
}, const []);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
@@ -126,6 +150,36 @@ class SettingsPage extends HookConsumerWidget {
|
||||
trailing: '占位',
|
||||
onTap: () => _showWidgetSheet(context),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_SwitchRow(
|
||||
title: '推送通知',
|
||||
subtitle: notificationEnabled.value
|
||||
? '已允许接收服务提醒'
|
||||
: '在系统设置中开启服务提醒',
|
||||
value: notificationEnabled.value,
|
||||
onChanged: (enabled) async {
|
||||
if (enabled) {
|
||||
final granted =
|
||||
await NotificationPermission.requestFromSettings();
|
||||
notificationEnabled.value = granted;
|
||||
if (!granted) {
|
||||
await NotificationPermission.openSystemSettings();
|
||||
}
|
||||
} else {
|
||||
await NotificationPermission.openSystemSettings();
|
||||
notificationEnabled.value =
|
||||
await NotificationPermission.isGranted();
|
||||
}
|
||||
},
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
icon: FlutterRemix.delete_bin_6_line,
|
||||
label: '清除缓存',
|
||||
trailing: '图片与网页缓存',
|
||||
showArrow: true,
|
||||
onTap: () => _confirmClearCache(context),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
@@ -144,18 +198,16 @@ class SettingsPage extends HookConsumerWidget {
|
||||
_NavRow(
|
||||
icon: FlutterRemix.file_list_3_line,
|
||||
label: '用户协议',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.userProtocolUrl))}',
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
icon: FlutterRemix.shield_check_line,
|
||||
label: '隐私政策',
|
||||
onTap: () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
onTap: () => context.push(
|
||||
'${AppRoutes.web}?url=${Uri.encodeComponent(AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl))}',
|
||||
),
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
@@ -192,7 +244,27 @@ class SettingsPage extends HookConsumerWidget {
|
||||
const _Section('关于'),
|
||||
_Card(
|
||||
children: [
|
||||
const _AboutRow(),
|
||||
_AboutRow(
|
||||
upgrade: upgrade,
|
||||
enabled: updateSupported,
|
||||
onTap: () async {
|
||||
try {
|
||||
final result = await ref
|
||||
.read(updateControllerProvider.notifier)
|
||||
.checkUpgrade();
|
||||
if (!context.mounted) return;
|
||||
await UpdateService.showIfNeeded(
|
||||
context,
|
||||
ref,
|
||||
result,
|
||||
source: UpgradeCheckSource.manual,
|
||||
onLatest: () => toastInfo(msg: '已经是最新版本'),
|
||||
);
|
||||
} catch (_) {
|
||||
if (context.mounted) toastInfo(msg: '检查更新失败');
|
||||
}
|
||||
},
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_NavRow(
|
||||
icon: FlutterRemix.mail_line,
|
||||
@@ -215,6 +287,38 @@ class SettingsPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmClearCache(BuildContext context) async {
|
||||
final systemSettings = CacheClearService.requiresSystemSettings;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('清除缓存'),
|
||||
content: Text(
|
||||
systemSettings
|
||||
? 'Android 需要在系统应用详情中完成缓存清理,是否立即前往?'
|
||||
: '将清除图片和网页缓存。下次打开相关页面时需要重新加载。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(systemSettings ? '前往设置' : '确认清除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (systemSettings) {
|
||||
await CacheClearService.openAndroidSystemSettings();
|
||||
return;
|
||||
}
|
||||
await CacheClearService.clear();
|
||||
if (context.mounted) toastInfo(msg: '缓存已清除');
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final ok = await showModalBottomSheet<bool>(
|
||||
@@ -788,7 +892,7 @@ class _ThemeSegmentCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final options = <(ThemeMode, String, IconData)>[
|
||||
(ThemeMode.system, '跟随系统', Icons.brightness_auto_outlined),
|
||||
(ThemeMode.system, '系统主题', Icons.brightness_auto_outlined),
|
||||
(ThemeMode.light, '浅色', Icons.light_mode_outlined),
|
||||
(ThemeMode.dark, '深色', Icons.dark_mode_outlined),
|
||||
];
|
||||
@@ -948,6 +1052,7 @@ class _NavRow extends StatelessWidget {
|
||||
required this.label,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.showArrow = false,
|
||||
this.destructive = false,
|
||||
});
|
||||
|
||||
@@ -955,6 +1060,7 @@ class _NavRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final bool showArrow;
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
@@ -981,7 +1087,7 @@ class _NavRow extends StatelessWidget {
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
if (onTap != null && trailing == null)
|
||||
if (onTap != null && (trailing == null || showArrow))
|
||||
Icon(
|
||||
FlutterRemix.arrow_right_s_line,
|
||||
size: 20,
|
||||
@@ -995,7 +1101,15 @@ class _NavRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _AboutRow extends StatelessWidget {
|
||||
const _AboutRow();
|
||||
const _AboutRow({
|
||||
required this.upgrade,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final UpgradeInfo? upgrade;
|
||||
final bool enabled;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1006,33 +1120,47 @@ class _AboutRow extends StatelessWidget {
|
||||
final v = snap.hasData
|
||||
? 'v${snap.data!.version}+${snap.data!.buildNumber}'
|
||||
: '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Icon(
|
||||
FlutterRemix.information_line,
|
||||
size: 20,
|
||||
color: cs.foreground,
|
||||
final label = upgrade?.hasPublishedUpdate == true
|
||||
? '有新版 ${upgrade!.latestVersion}'
|
||||
: v;
|
||||
return InkWell(
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Icon(
|
||||
FlutterRemix.information_line,
|
||||
size: 20,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'金值',
|
||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'金值',
|
||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
v,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
Text(
|
||||
label,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (enabled) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
FlutterRemix.arrow_right_s_line,
|
||||
size: 20,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class MallWebHostState {
|
||||
const MallWebHostState({this.url, this.visible = false});
|
||||
final String? url;
|
||||
final bool visible;
|
||||
}
|
||||
|
||||
/// 进程常驻的商城 WebView 状态,切换原生 Tab 时不销毁商城页面。
|
||||
class MallWebHostController {
|
||||
MallWebHostController._();
|
||||
static final instance = MallWebHostController._();
|
||||
final state = ValueNotifier(const MallWebHostState());
|
||||
Future<void> Function()? _backHandler;
|
||||
|
||||
void preload(String url) {
|
||||
final old = state.value;
|
||||
if (old.url == url) return;
|
||||
state.value = MallWebHostState(url: url, visible: old.visible);
|
||||
}
|
||||
|
||||
void open(String url) =>
|
||||
state.value = MallWebHostState(url: url, visible: true);
|
||||
void dismiss() => state.value = MallWebHostState(url: state.value.url);
|
||||
void clear() => state.value = const MallWebHostState();
|
||||
void attachBackHandler(Future<void> Function() handler) =>
|
||||
_backHandler = handler;
|
||||
void detachBackHandler() => _backHandler = null;
|
||||
Future<void> handleBack() => _backHandler?.call() ?? Future.value();
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../application/mall_web_host_controller.dart';
|
||||
|
||||
/// 常驻在 Tab Shell 上层的商城 H5 宿主。
|
||||
class MallWebHost extends StatefulWidget {
|
||||
const MallWebHost({super.key, required this.onExit});
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
State<MallWebHost> createState() => _MallWebHostState();
|
||||
}
|
||||
|
||||
class _MallWebHostState extends State<MallWebHost> {
|
||||
final _host = MallWebHostController.instance;
|
||||
WebViewController? _controller;
|
||||
String? _loadedUrl;
|
||||
String? _error;
|
||||
int _progress = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_host.state.addListener(_sync);
|
||||
_host.attachBackHandler(_goBack);
|
||||
_sync();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.state.removeListener(_sync);
|
||||
_host.detachBackHandler();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
final url = _host.state.value.url;
|
||||
if (url != null && url != _loadedUrl) _load(url);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _load(String rawUrl) {
|
||||
final uri = Uri.tryParse(rawUrl);
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
_error = '商城链接不可用';
|
||||
return;
|
||||
}
|
||||
_error = null;
|
||||
_progress = 0;
|
||||
_loadedUrl = rawUrl;
|
||||
_controller ??= WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel(
|
||||
'JinzhiBridge',
|
||||
onMessageReceived: (message) {
|
||||
if (message.message == 'close') widget.onExit();
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (value) {
|
||||
if (mounted) setState(() => _progress = value);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == false) return;
|
||||
if (mounted) setState(() => _error = error.description);
|
||||
},
|
||||
onNavigationRequest: (request) async {
|
||||
final target = Uri.tryParse(request.url);
|
||||
if (target == null ||
|
||||
target.scheme == 'http' ||
|
||||
target.scheme == 'https') {
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
if (await canLaunchUrl(target)) {
|
||||
await launchUrl(target, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
return NavigationDecision.prevent;
|
||||
},
|
||||
),
|
||||
);
|
||||
unawaited(_controller!.loadRequest(uri));
|
||||
}
|
||||
|
||||
Future<void> _goBack() async {
|
||||
final controller = _controller;
|
||||
if (controller != null && await controller.canGoBack()) {
|
||||
await controller.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = _host.state.value;
|
||||
if (current.url == null) return const SizedBox.shrink();
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_controller != null)
|
||||
Positioned.fill(child: WebViewWidget(controller: _controller!)),
|
||||
if (_error != null)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off_rounded, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
const Text('商城加载失败'),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => _load(_loadedUrl ?? ''),
|
||||
child: const Text('重新加载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_error == null && _progress < 100)
|
||||
const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
/// Shared HTTPS WebView host. Mall-specific residency and attribution are
|
||||
/// layered above this page so ordinary H5 links never receive mall state.
|
||||
class AppWebPage extends StatefulWidget {
|
||||
const AppWebPage({
|
||||
super.key,
|
||||
required this.initialUri,
|
||||
this.hideTitle = false,
|
||||
this.allowedHosts = const <String>{},
|
||||
});
|
||||
|
||||
final Uri initialUri;
|
||||
final bool hideTitle;
|
||||
final Set<String> allowedHosts;
|
||||
|
||||
@override
|
||||
State<AppWebPage> createState() => _AppWebPageState();
|
||||
}
|
||||
|
||||
class _AppWebPageState extends State<AppWebPage> {
|
||||
late final WebViewController _controller;
|
||||
var _progress = 0;
|
||||
String? _error;
|
||||
String _title = '';
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
NavigationDecision _onNavigationRequest(NavigationRequest request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null || !_isHttp(uri)) return NavigationDecision.prevent;
|
||||
if (_allowsInApp(uri)) return NavigationDecision.navigate;
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
bool _allowsInApp(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
if (host == widget.initialUri.host.toLowerCase()) return true;
|
||||
return widget.allowedHosts.any(
|
||||
(allowed) => host == allowed || host.endsWith('.$allowed'),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isHttp(Uri uri) =>
|
||||
uri.hasAuthority && (uri.scheme == 'https' || uri.scheme == 'http');
|
||||
|
||||
Future<bool> _onPop() async {
|
||||
if (await _controller.canGoBack()) {
|
||||
await _controller.goBack();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = _error == null
|
||||
? Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_progress < 100)
|
||||
LinearProgressIndicator(value: _progress / 100),
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('页面加载失败\n$_error', textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (!didPop && await _onPop() && context.mounted) context.pop();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: widget.hideTitle
|
||||
? null
|
||||
: AppBar(title: Text(_title.isEmpty ? '加载中' : _title)),
|
||||
body: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Uri? parseSafeWebUri(String? raw) {
|
||||
final uri = Uri.tryParse(raw?.trim() ?? '');
|
||||
if (uri == null || !uri.hasAuthority) return null;
|
||||
return uri.scheme == 'https' || uri.scheme == 'http' ? uri : null;
|
||||
}
|
||||
Reference in new issue
Block a user