fix:第一轮跑

This commit is contained in:
jingyun committed 2026-08-24 19:07:31 +08:00
1 parent e834ec7586
commit 33235f450b
56 files changed
+4357 -210

No files matched your search

+52 -2
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -5,15 +7,63 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../common/router/app_router.dart';
import '../common/lifecycle/refresh_coordinator.dart';
import '../common/push/device_push_registrar.dart';
import '../common/push/umeng_push_service.dart';
import '../common/theme/jinzhi_theme.dart';
import '../common/theme/spacing.dart';
import '../common/theme/theme_controller.dart';
import '../features/ads/application/ad_config_foreground_refresher.dart';
import '../features/settings/application/update_foreground_service.dart';
import '../common/router/deep_link_service.dart';
import '../common/services/device_header_service.dart';
class JinzhiApp extends ConsumerWidget {
class JinzhiApp extends ConsumerStatefulWidget {
const JinzhiApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<JinzhiApp> createState() => _JinzhiAppState();
}
class _JinzhiAppState extends ConsumerState<JinzhiApp>
with WidgetsBindingObserver {
late final DevicePushRegistrar _pushRegistrar;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pushRegistrar = DevicePushRegistrar(ref.read)..start();
UmengPushService.configureNotificationOpen(DeepLinkService.handle);
WidgetsBinding.instance.addPostFrameCallback((_) {
// 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页
// 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。
if (!DeviceHeaderService.to.iosStartupRequired) {
unawaited(UmengPushService.startIfAllowed());
}
});
}
@override
void dispose() {
unawaited(_pushRegistrar.stop());
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_pushRegistrar.refresh());
unawaited(ref.read(adConfigForegroundRefresherProvider).refresh());
final context = rootNavigatorKey.currentContext;
if (context != null) {
unawaited(UpdateForegroundService.check(context, ref));
}
}
}
@override
Widget build(BuildContext context) {
// Keep the app-wide foreground refresh coordinator alive. Features opt in
// by registering only while their screen is visible.
ref.watch(refreshCoordinatorProvider);
+7 -4
View File
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/widgets.dart';
import '../common/services/device_header_service.dart';
import '../common/config/shorebird_service.dart';
import '../common/config/umeng_config.dart';
import '../common/storage/secure_identity_storage.dart';
import '../common/storage/storage_service.dart';
import '../common/widgets/easy_refresh_localization.dart';
@@ -12,6 +14,7 @@ import 'app.dart';
Future<Widget> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
configureEasyRefreshChineseLocalization();
await UmengConfig.load();
await StorageService.instance;
await SecureIdentityStorage(
StorageService.to.preferences,
@@ -25,9 +28,9 @@ Future<Widget> bootstrap() async {
await DeviceHeaderService.to.ensureInitialized();
}
}
// if (!kIsWeb) {
// await ShorebirdService.instance.initCurrentPatch();
// ShorebirdService.instance.checkForUpdateInBackground();
// }
if (!kIsWeb) {
await ShorebirdService.instance.initCurrentPatch();
ShorebirdService.instance.checkForUpdateInBackground();
}
return const JinzhiApp();
}
+1 -1
View File
@@ -4,7 +4,7 @@ class AppConfig {
factory AppConfig.fromEnvironment() {
const url = String.fromEnvironment(
'JINZHI_API_BASE_URL',
defaultValue: 'https://jinzhi.api.local',
defaultValue: 'https://yanting.api.hnsiderui.cn',
);
return const AppConfig(apiBaseUrl: url);
}
+1 -1
View File
@@ -3,7 +3,7 @@ class AppH5Urls {
static const baseUrl = String.fromEnvironment(
'JINZHI_H5_BASE_URL',
defaultValue: 'https://jinzhi.h5.local',
defaultValue: 'https://yanting.h5.hnsiderui.cn',
);
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
+7
View File
@@ -7,6 +7,8 @@ class UmengConfig {
'6a27b1f46f259537c7b616be'; // Default placeholder
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
static String _androidPushSecret = 'xxx'; // Default placeholder
static String _androidLoginSecret = '';
static String _iosLoginSecret = '';
static Future<void> load() async {
await dotenv.load(
@@ -17,9 +19,14 @@ class UmengConfig {
_iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
_androidPushSecret =
dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret;
_androidLoginSecret =
dotenv.env['UMENG_ANDROID_LOGIN_SECRET'] ?? _androidLoginSecret;
_iosLoginSecret = dotenv.env['UMENG_IOS_LOGIN_SECRET'] ?? _iosLoginSecret;
}
static String get androidAppKey => _androidAppKey;
static String get iosAppKey => _iosAppKey;
static String get androidPushSecret => _androidPushSecret;
static String get androidLoginSecret => _androidLoginSecret;
static String get iosLoginSecret => _iosLoginSecret;
}
@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
abstract final class UmengPrivacyConsent {
static const _channel = MethodChannel('jinzhi/privacy');
static Future<void> submitGranted() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
await _channel.invokeMethod<void>('submitUmengPrivacyGranted');
}
}
@@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../network/dio_provider.dart';
import 'notification_permission.dart';
import 'umeng_push_service.dart';
class DevicePushRegistrar {
DevicePushRegistrar(this._read);
final T Function<T>(ProviderListenable<T>) _read;
StreamSubscription<String>? _subscription;
String? _lastToken;
bool? _lastPermission;
int _attempt = 0;
void start() {
_subscription ??= UmengPushService.onDeviceToken.listen(_register);
UmengPushService.emitCachedToken();
}
Future<void> stop() async => _subscription?.cancel();
Future<void> refresh() => UmengPushService.refreshDeviceToken();
void forceRegisterCurrentToken() {
_lastToken = null;
_lastPermission = null;
UmengPushService.emitCachedToken();
}
Future<void> _register(String token) async {
final permission = await NotificationPermission.isGranted();
if (token == _lastToken && permission == _lastPermission) return;
try {
final package = await PackageInfo.fromPlatform();
await _read(dioProvider).post<List<int>>(
'/api/v1/push/devices',
data: _PushRegisterRequest(
token: token,
platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2,
version: package.version,
permission: permission,
).toBytes(),
options: protobufOptions(),
);
_lastToken = token;
_lastPermission = permission;
_attempt = 0;
} catch (_) {
if (_attempt++ >= 3) return;
await Future<void>.delayed(Duration(seconds: 1 << (_attempt - 1)));
await _register(token);
}
}
}
class _PushRegisterRequest {
const _PushRegisterRequest({
required this.token,
required this.platform,
required this.version,
required this.permission,
});
final String token;
final int platform;
final String version;
final bool permission;
Uint8List toBytes() {
final bytes = BytesBuilder(copy: false);
void putInt(int value) {
while (value > 0x7f) {
bytes.addByte((value & 0x7f) | 0x80);
value >>= 7;
}
bytes.addByte(value);
}
void putString(int tag, String value) {
final raw = value.codeUnits;
putInt((tag << 3) | 2);
putInt(raw.length);
bytes.add(raw);
}
putString(1, token);
putInt(2 << 3);
putInt(platform);
putString(3, version);
putInt(4 << 3);
putInt(permission ? 1 : 0);
return bytes.toBytes();
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import '../storage/storage_keys.dart';
import '../storage/storage_service.dart';
import '../widgets/notification_permission_tip.dart';
abstract final class NotificationPermission {
static Future<bool> isGranted() async =>
!kIsWeb && (await Permission.notification.status).isGranted;
/// 对齐研听:Android 13+ 由应用解释后主动申请;iOS 由 U-Push 注册 APNs
/// 时触发系统授权,绝不展示 Android 的自定义顶部提示。
static Future<bool> requestIfNeeded(BuildContext context) async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return isGranted();
}
final android = await DeviceInfoPlugin().androidInfo;
if (android.version.sdkInt < 33 ||
StorageService.to.getBool(storagePushPermissionPrompted)) {
return isGranted();
}
if (await isGranted() || !context.mounted) return true;
await showNotificationPermissionTip(context);
await StorageService.to.setBool(storagePushPermissionPrompted, true);
return (await Permission.notification.request()).isGranted;
}
/// 设置页不使用顶部说明;iOS/Android 都直接请求系统授权。
static Future<bool> requestFromSettings() async {
if (kIsWeb) return false;
final current = await Permission.notification.status;
if (current.isGranted) return true;
await StorageService.to.setBool(storagePushPermissionPrompted, true);
return (await Permission.notification.request()).isGranted;
}
static Future<bool> openSystemSettings() => openAppSettings();
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:umeng_common_sdk/umeng_common_sdk.dart';
import 'package:umeng_push_sdk/umeng_push_sdk.dart';
import '../config/umeng_config.dart';
import '../privacy/privacy_gate.dart';
import '../services/device_header_service.dart';
import '../storage/storage_keys.dart';
import '../storage/storage_service.dart';
abstract final class UmengPushService {
static final _tokens = StreamController<String>.broadcast();
static Future<void>? _starting;
static bool _started = false;
static void Function(String payload)? _onNotificationOpen;
static String? _pendingNotificationOpen;
static Stream<String> get onDeviceToken => _tokens.stream;
static void configureNotificationOpen(
void Function(String payload) callback,
) {
_onNotificationOpen = callback;
final pending = _pendingNotificationOpen;
if (pending != null) {
_pendingNotificationOpen = null;
callback(pending);
}
}
static Future<void> startIfAllowed() {
if (kIsWeb ||
_started ||
requiresAndroidPrivacyConsent(
isWeb: kIsWeb,
platform: defaultTargetPlatform,
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
)) {
return Future.value();
}
return _starting ??= _start().whenComplete(() => _starting = null);
}
static Future<void> _start() async {
await DeviceHeaderService.to.ensureInitialized();
await UmengCommonSdk.initCommon(
UmengConfig.androidAppKey,
UmengConfig.iosAppKey,
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
UmengConfig.androidPushSecret,
);
UmengPushSdk.setTokenCallback(_emit);
UmengPushSdk.setNotificationCallback((_) {}, (payload) {
final callback = _onNotificationOpen;
if (callback == null) {
_pendingNotificationOpen = payload;
} else {
callback(payload);
}
});
await UmengPushSdk.register(
UmengConfig.iosAppKey,
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'test',
);
if (Platform.isAndroid) await UmengPushSdk.setPushEnable(true);
_started = true;
await refreshDeviceToken();
}
static Future<void> refreshDeviceToken() async {
if (!_started || kIsWeb) return;
_emit(await UmengPushSdk.getRegisteredId());
}
static void emitCachedToken() =>
_emit(StorageService.to.getString(storageDeviceToken));
static void _emit(String? token) {
if (token == null || token.isEmpty) return;
_tokens.add(token);
unawaited(StorageService.to.setString(storageDeviceToken, token));
}
}
+20
View File
@@ -15,8 +15,10 @@ import '../../features/news/presentation/news_page.dart';
import '../../features/profile/presentation/profile_page.dart';
import '../../features/settings/presentation/settings_page.dart';
import '../../features/settings/presentation/privacy_consent_page.dart';
import '../../features/web/presentation/web_page.dart';
import '../services/device_header_service.dart';
import 'tab_shell.dart';
import 'route_error_page.dart';
abstract final class AppRoutes {
static const assets = '/assets';
@@ -33,13 +35,18 @@ abstract final class AppRoutes {
static const debugTypography = '/debug/typography';
static const fancyDioInspector = '/debug/fancy_dio_inspector';
static const privacyConsent = '/privacy-consent';
static const web = '/web';
}
final rootNavigatorKey = GlobalKey<NavigatorState>();
final routerProvider = Provider<GoRouter>((ref) {
final startupListenable = ValueNotifier<int>(0);
return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: AppRoutes.assets,
refreshListenable: startupListenable,
errorBuilder: (_, state) => RouteErrorPage(error: state.error),
redirect: (context, state) {
final loc = state.matchedLocation;
final onConsent = loc == AppRoutes.privacyConsent;
@@ -131,6 +138,19 @@ final routerProvider = Provider<GoRouter>((ref) {
path: AppRoutes.privacyConsent,
builder: (_, s) => const PrivacyConsentPage(),
),
GoRoute(
path: AppRoutes.web,
builder: (_, state) {
final uri = parseSafeWebUri(state.uri.queryParameters['url']);
if (uri == null) {
return const Scaffold(body: Center(child: Text('页面地址不可用')));
}
return AppWebPage(
initialUri: uri,
hideTitle: state.uri.queryParameters['hideTitle'] == '1',
);
},
),
],
);
});
+43
View File
@@ -0,0 +1,43 @@
import 'dart:convert';
import 'package:go_router/go_router.dart';
import 'app_router.dart';
/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。
abstract final class DeepLinkService {
static void handle(String raw) {
final target = _route(raw);
if (target != null) rootNavigatorKey.currentContext?.go(target);
}
static String? _route(String raw) {
var source = raw.trim();
try {
final json = jsonDecode(source);
if (json is Map) {
source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}';
}
} catch (_) {}
final uri = Uri.tryParse(source);
if (uri == null) return null;
if (uri.scheme == 'jinzhi') {
final path = uri.path.isEmpty ? '/assets' : uri.path;
return _allowed(path)
? '$path${uri.hasQuery ? '?${uri.query}' : ''}'
: null;
}
if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) {
return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}';
}
return null;
}
static bool _allowed(String path) => const {
'/assets',
'/market',
'/news',
'/profile',
'/settings',
}.contains(path);
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class RouteErrorPage extends StatelessWidget {
const RouteErrorPage({super.key, required this.error});
final Object? error;
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.explore_off_outlined, size: 40),
const SizedBox(height: 14),
const Text('页面不存在或已失效'),
const SizedBox(height: 12),
FilledButton(
onPressed: () => context.go('/assets'),
child: const Text('回到首页'),
),
],
),
),
),
);
}
+226 -90
View File
@@ -1,13 +1,18 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../features/mall/application/mall_tab_config.dart';
import '../../features/web/application/mall_web_host_controller.dart';
import '../../features/web/presentation/mall_web_host.dart';
import '../theme/app_icons.dart';
import '../widgets/adaptive.dart';
import 'app_router.dart';
class TabShell extends StatefulWidget {
class TabShell extends ConsumerStatefulWidget {
const TabShell({
super.key,
required this.navigationShell,
@@ -18,16 +23,29 @@ class TabShell extends StatefulWidget {
final String location;
@override
State<TabShell> createState() => _TabShellState();
ConsumerState<TabShell> createState() => _TabShellState();
}
class _TabShellState extends State<TabShell> {
class _TabShellState extends ConsumerState<TabShell> {
static const double _compactHeaderThreshold = 34;
bool _showCompactHeader = false;
int get _index => widget.navigationShell.currentIndex;
@override
void initState() {
super.initState();
ref.listenManual<MallTabConfig>(mallTabConfigProvider, (_, next) {
if (!next.isVisible) return;
final uri = next.attributedUri(
clickId: '',
isDark: Theme.of(context).brightness == Brightness.dark,
);
if (uri != null) MallWebHostController.instance.preload(uri.toString());
});
}
@override
void didUpdateWidget(covariant TabShell oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -36,9 +54,28 @@ class _TabShellState extends State<TabShell> {
}
}
Future<void> _openMall(MallTabConfig config) async {
final isDark = Theme.of(context).brightness == Brightness.dark;
final actual = await ref
.read(mallTabConfigProvider.notifier)
.ensureLoaded();
if (!actual.isVisible || !mounted) return;
final clickId = await ref
.read(mallTabConfigProvider.notifier)
.recordClick();
final uri = actual.attributedUri(clickId: clickId ?? '', isDark: isDark);
if (uri == null) return;
if (actual.opensInApp) {
MallWebHostController.instance.open(uri.toString());
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final mall = ref.watch(mallTabConfigProvider);
final header = _headerForPath(widget.location);
final canPop = GoRouter.of(context).canPop();
@@ -47,111 +84,144 @@ class _TabShellState extends State<TabShell> {
resizeToAvoidBottomInset: false,
body: Stack(
children: [
Scaffold(
backgroundColor: cs.background,
resizeToAvoidBottomInset: false,
appBar: AppBar(
PopScope(
canPop: !MallWebHostController.instance.state.value.visible,
onPopInvokedWithResult: (didPop, _) {
if (!didPop &&
MallWebHostController.instance.state.value.visible) {
MallWebHostController.instance.handleBack();
}
},
child: Scaffold(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
toolbarHeight: 32,
leading: canPop
? IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.chevron_left, size: 20),
)
: null,
title: AnimatedOpacity(
opacity: _showCompactHeader ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
header.title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: cs.foreground,
height: 1.1,
letterSpacing: 0,
),
),
if (header.subtitle != null) ...[
const SizedBox(height: 2),
resizeToAvoidBottomInset: false,
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
toolbarHeight: 32,
leading: canPop
? IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.chevron_left, size: 20),
)
: null,
title: AnimatedOpacity(
opacity: _showCompactHeader ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
header.subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
header.title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: cs.mutedForeground,
fontSize: 15,
fontWeight: FontWeight.w700,
color: cs.foreground,
height: 1.1,
letterSpacing: 0,
),
),
if (header.subtitle != null) ...[
const SizedBox(height: 2),
Text(
header.subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: cs.mutedForeground,
height: 1.1,
letterSpacing: 0,
),
),
],
],
],
),
),
),
),
body: ColoredBox(
color: cs.background,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: widget.navigationShell,
body: ColoredBox(
color: cs.background,
child: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: widget.navigationShell,
),
),
),
bottomNavigationBar: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: cs.background,
border: Border(
top: BorderSide(color: cs.border, width: 1),
bottomNavigationBar: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: cs.background,
border: Border(
top: BorderSide(color: cs.border, width: 1),
),
),
),
child: SizedBox(
height: 62,
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.tabsMaxWidth,
),
child: Row(
children: [
for (var i = 0; i < _labels.length; i++)
Expanded(
child: _TabButton(
icon: i == _index
? _filledIcons[i]
: _outlineIcons[i],
label: _labels[i],
selected: i == _index,
onTap: () =>
widget.navigationShell.goBranch(
i,
initialLocation: i == _index,
),
child: SizedBox(
height: 62,
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.tabsMaxWidth,
),
child: Row(
children: [
for (var i = 0; i < _labels.length; i++)
Expanded(
child: _TabButton(
icon: i == _index
? _filledIcons[i]
: _outlineIcons[i],
label: _labels[i],
selected: i == _index,
onTap: () =>
widget.navigationShell.goBranch(
i,
initialLocation: i == _index,
),
),
),
),
],
],
),
),
),
),
),
),
],
],
),
),
),
),
if (mall.isVisible)
Positioned(
bottom: 39,
left: 0,
right: 0,
child: Center(
child: _MallDockButton(
title: mall.title,
badge: mall.badgeText,
onTap: () => _openMall(mall),
),
),
),
ValueListenableBuilder<MallWebHostState>(
valueListenable: MallWebHostController.instance.state,
builder: (context, state, _) => Positioned.fill(
child: Offstage(
offstage: !state.visible,
child: MallWebHost(
onExit: MallWebHostController.instance.dismiss,
),
),
),
),
@@ -188,6 +258,72 @@ class _TabShellState extends State<TabShell> {
}
}
class _MallDockButton extends StatelessWidget {
const _MallDockButton({
required this.title,
required this.badge,
required this.onTap,
});
final String title;
final String badge;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Semantics(
button: true,
label: title,
child: Material(
color: cs.primary,
borderRadius: BorderRadius.circular(24),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.shopping_bag_outlined,
size: 18,
color: cs.primaryForeground,
),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
color: cs.primaryForeground,
fontWeight: FontWeight.w700,
),
),
if (badge.isNotEmpty) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
decoration: BoxDecoration(
color: cs.background,
borderRadius: BorderRadius.circular(8),
),
child: Text(
badge,
style: TextStyle(fontSize: 10, color: cs.foreground),
),
),
],
],
),
),
),
),
);
}
}
class _TabButton extends StatelessWidget {
const _TabButton({
required this.icon,
@@ -0,0 +1,38 @@
import 'dart:io' show Platform;
import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
import '../storage/storage_service.dart';
/// Clears only disposable presentation data. Credentials and user preferences
/// deliberately remain outside this service.
class CacheClearService {
const CacheClearService._();
static Future<void> clear() async {
await DefaultCacheManager().emptyCache();
await SplashAdControllerPlaceholder.clear();
}
static bool get requiresSystemSettings => !kIsWeb && Platform.isAndroid;
static Future<void> openAndroidSystemSettings() async {
final packageName = (await PackageInfo.fromPlatform()).packageName;
await AndroidIntent(
action: 'android.settings.APPLICATION_DETAILS_SETTINGS',
data: 'package:$packageName',
).launch();
}
}
/// CacheClearService is used outside Riverpod; the splash entry itself lives
/// in SharedPreferences and is removed here without touching user data.
abstract final class SplashAdControllerPlaceholder {
static Future<void> clear() async {
await StorageService.to.preferences.remove('ads.splash.entry');
await StorageService.to.preferences.remove('ads.splash.daily');
}
}
@@ -185,6 +185,12 @@ class DeviceHeaderService {
await StorageService.to.setBool(storagePrivacyAgreed, true);
}
/// ATT 授权后写入 IDFA;后续请求通过 platformIdentityHeaders 自动带上。
Future<void> saveIosIdfa(String value) async {
if (value.trim().isEmpty) return;
await StorageService.to.setString(storageIosIdfa, value.trim());
}
/// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
bool get privacyConsentRequired => requiresAndroidPrivacyConsent(
+1
View File
@@ -7,6 +7,7 @@ const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
const storagePushPermissionPrompted = 'push_permission_prompted';
// Previous builds could persist credentials in SharedPreferences. These keys
// exist only for one-time migration into secure storage.
+76
View File
@@ -0,0 +1,76 @@
import 'dart:async';
import 'dart:io';
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
import 'package:flutter/foundation.dart';
import 'package:umeng_common_sdk/umeng_common_sdk.dart';
import 'package:umeng_verify_sdk/umeng_verify_sdk.dart';
import '../config/umeng_config.dart';
import '../services/device_header_service.dart';
import '../storage/storage_keys.dart';
import '../storage/storage_service.dart';
/// 合规门禁后的友盟公共初始化入口;运营商认证只可由显式登录动作触发。
abstract final class UmengService {
static Future<void>? _initializing;
static bool _ready = false;
static Future<void>? _oneKeyInitializing;
static bool _oneKeyReady = false;
static Future<void> initIfAllowed() {
if (_ready || kIsWeb) return Future.value();
if (Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
return Future.value();
}
return _initializing ??= _init().whenComplete(() => _initializing = null);
}
static Future<void> _init() async {
await DeviceHeaderService.to.ensureInitialized();
await UmengCommonSdk.initCommon(
UmengConfig.androidAppKey,
UmengConfig.iosAppKey,
DeviceHeaderService.to.commonHeaders()['User-Channel'] ?? 'default',
UmengConfig.androidPushSecret,
);
_ready = true;
}
/// 研听最新合规策略:禁止启动、隐私同意、推送注册等路径预取运营商认证。
/// 该方法仅允许在用户明确点按登录后由 [OneKeyLoginService] 调用。
static Future<void> initOneKeyLoginIfAllowed() async {
if (_oneKeyReady || kIsWeb) return;
if (Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
return;
}
await initIfAllowed();
return _oneKeyInitializing ??= _initOneKey().whenComplete(
() => _oneKeyInitializing = null,
);
}
static Future<void> _initOneKey() async {
if (Platform.isAndroid) UmengVerifySdk.register_android();
await UmengVerifySdk.setVerifySDKInfo(
UmengConfig.androidLoginSecret,
UmengConfig.iosLoginSecret,
);
_oneKeyReady = true;
}
static Future<void> requestTrackingAuthorizationIfNeeded() async {
if (kIsWeb || !Platform.isIOS) return;
try {
final status =
await AppTrackingTransparency.requestTrackingAuthorization();
if (status != TrackingStatus.authorized) return;
final idfa = await AppTrackingTransparency.getAdvertisingIdentifier();
await DeviceHeaderService.to.saveIosIdfa(idfa);
} catch (_) {
// ATT 拒绝/不可用不能阻断启动。
}
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// A short in-app explanation displayed immediately before the system prompt.
class NotificationPermissionTip extends StatelessWidget {
const NotificationPermissionTip({super.key});
@override
Widget build(BuildContext context) {
final colors = ShadTheme.of(context).colorScheme;
return IgnorePointer(
child: Align(
alignment: Alignment.topCenter,
child: Material(
type: MaterialType.transparency,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration(
color: colors.foreground,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: Row(
children: [
Icon(
Icons.notifications_outlined,
size: 20,
color: colors.background,
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'通知权限使用说明',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.background,
),
),
const SizedBox(height: 2),
Text(
'用于向你传递重要服务提醒。',
style: TextStyle(
fontSize: 12,
color: colors.background,
),
),
],
),
),
],
),
),
),
),
),
),
);
}
}
Future<void> showNotificationPermissionTip(BuildContext context) async {
final overlay = Overlay.of(context, rootOverlay: true);
final entry = OverlayEntry(builder: (_) => const NotificationPermissionTip());
overlay.insert(entry);
await Future<void>.delayed(const Duration(milliseconds: 500));
entry.remove();
}
@@ -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());
}
}
}
+294
View File
@@ -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);
}
}
+122
View File
@@ -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),
),
],
),
);
}
}
+123
View File
@@ -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;
}