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

@@ -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,
),
],
],
),
),
);
},