fix:第一轮跑
This commit is contained in:
1 parent
e834ec7586
commit
33235f450b
56 files changed
+4357
-210
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('回到首页'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user