feat:参照研听的基础工程

This commit is contained in:
jingyun
2026-06-18 15:02:28 +08:00
commit 364fc837b3
367 changed files with 16495 additions and 0 deletions
@@ -0,0 +1,176 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
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:package_info_plus/package_info_plus.dart';
import '../../../common/config/shorebird_service.dart';
import '../../../common/network/auth_interceptor.dart';
import '../../../common/network/dio_provider.dart';
import '../../../common/router/app_router.dart';
import '../../../common/services/device_header_service.dart';
import '../../../common/storage/storage_keys.dart';
import '../../../common/storage/storage_service.dart';
import '../../../common/theme/theme_controller.dart';
class DebugInfoPage extends ConsumerWidget {
const DebugInfoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appConfig = ref.watch(appConfigProvider);
final dio = ref.watch(dioProvider);
final themeMode = ref.watch(themeModeControllerProvider);
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.debug);
}
},
icon: const Icon(Icons.arrow_back_ios_new),
),
title: const Text('Info'),
),
body: FutureBuilder<_DebugInfoSnapshot>(
future: _loadSnapshot(
apiBaseUrl: appConfig.apiBaseUrl,
themeMode: themeMode.name,
dio: dio,
),
builder: (context, snapshot) {
final text = snapshot.data?.text ?? 'loading...';
return Padding(
padding: const EdgeInsets.all(16),
child: SelectableText(text, style: const TextStyle(fontSize: 12)),
);
},
),
);
}
}
Future<_DebugInfoSnapshot> _loadSnapshot({
required String apiBaseUrl,
required String themeMode,
required Dio dio,
}) async {
await DeviceHeaderService.to.ensureInitialized();
final packageInfo = await PackageInfo.fromPlatform();
final deviceInfo = await _readDeviceInfo();
final storage = StorageService.to;
final jwt = await loadJwt();
final commonHeaders = DeviceHeaderService.to.commonHeaders();
final platformHeaders = DeviceHeaderService.to.platformIdentityHeaders();
final headers = <String, dynamic>{
...commonHeaders,
...platformHeaders,
...dio.options.headers,
};
final gitVersion = const String.fromEnvironment(
'GITVERSION',
defaultValue: 'unknown',
);
final patch = ShorebirdService.instance.currentPatch;
final jwtLine = jwt?.isNotEmpty == true ? 'Bearer <redacted>' : '';
final text = StringBuffer()
..writeln('[build]')
..writeln('app_name: ${packageInfo.appName}')
..writeln('mode: ${kReleaseMode ? 'release' : 'debug'}')
..writeln('package_name: ${packageInfo.packageName}')
..writeln('version: ${packageInfo.version}')
..writeln('build_number: ${packageInfo.buildNumber}')
..writeln('theme_mode: $themeMode')
..writeln('platform: ${defaultTargetPlatform.name}')
..writeln('is_web: $kIsWeb')
..writeln()
..writeln('[environment]')
..writeln('api_base: $apiBaseUrl')
..writeln(
'connect_timeout_ms: ${dio.options.connectTimeout?.inMilliseconds}',
)
..writeln(
'receive_timeout_ms: ${dio.options.receiveTimeout?.inMilliseconds}',
)
..writeln('response_type: ${dio.options.responseType.name}')
..writeln('git_version: $gitVersion')
..writeln()
..writeln('[storage]')
..writeln('privacy_agreed: ${storage.getBool(storagePrivacyAgreed)}')
..writeln('ios_startup_seen: ${storage.getBool(storageIosStartupSeen)}')
..writeln('device_id: ${DeviceHeaderService.to.deviceId}')
..writeln('android_id: ${storage.getString(storageAndroidId) ?? ''}')
..writeln('device_token: ${storage.getString(storageDeviceToken) ?? ''}')
..writeln('android_oaid: ${storage.getString(storageAndroidOaid) ?? ''}')
..writeln('android_imei: ${storage.getString(storageAndroidImei) ?? ''}')
..writeln('android_mac: ${storage.getString(storageAndroidMac) ?? ''}')
..writeln('ios_idfa: ${storage.getString(storageIosIdfa) ?? ''}')
..writeln('ios_paid: ${storage.getString(storageIosPaid) ?? ''}')
..writeln()
..writeln('[auth]')
..writeln('jwt: $jwtLine')
..writeln()
..writeln('[headers]')
..writeln('common: $commonHeaders')
..writeln('platform: $platformHeaders')
..writeln('dio: $headers')
..writeln()
..writeln('[shorebird]')
..writeln('current_patch: ${patch?.number ?? 'none'}')
..writeln('patch: ${patch ?? 'none'}')
..writeln()
..writeln('[device]')
..writeln(deviceInfo);
return _DebugInfoSnapshot(text.toString());
}
Future<String> _readDeviceInfo() async {
try {
if (!kIsWeb &&
defaultTargetPlatform == TargetPlatform.android &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
return 'privacy policy not accepted; device info not read';
}
final plugin = DeviceInfoPlugin();
if (kIsWeb) {
final info = await plugin.webBrowserInfo;
return info.data.toString();
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
final info = await plugin.androidInfo;
return info.data.toString();
case TargetPlatform.iOS:
final info = await plugin.iosInfo;
return info.data.toString();
case TargetPlatform.macOS:
final info = await plugin.macOsInfo;
return info.data.toString();
case TargetPlatform.windows:
final info = await plugin.windowsInfo;
return info.data.toString();
case TargetPlatform.linux:
final info = await plugin.linuxInfo;
return info.data.toString();
case TargetPlatform.fuchsia:
return 'fuchsia';
}
} catch (error) {
return 'unavailable: $error';
}
}
class _DebugInfoSnapshot {
const _DebugInfoSnapshot(this.text);
final String text;
}
@@ -0,0 +1,201 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/router/app_router.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/spacing.dart';
class DebugPage extends StatelessWidget {
const DebugPage({super.key});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
'调试工具',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
onPressed: () => context.pop(),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
children: [
_Section(
title: '状态',
child: _Card(
child: Text(
kReleaseMode ? 'release' : 'debug',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
),
),
_Section(
title: '入口',
child: _Card(
children: [
_Row(
icon: Icons.data_object_outlined,
title: '请求',
subtitle: '查看 Dio 请求和响应',
onTap: () => context.push(AppRoutes.fancyDioInspector),
),
_Row(
icon: Icons.info_outline,
title: '应用信息',
subtitle: '版本、包名和构建信息',
onTap: () => context.push(AppRoutes.debugInfo),
),
_Row(
icon: Icons.text_fields,
title: 'Typography',
subtitle: '查看字号和文本样式基线',
onTap: () => context.push(AppRoutes.debugTypography),
),
_Row(
icon: Icons.system_update_alt_outlined,
title: 'Shorebird',
subtitle: '查看补丁与更新状态',
onTap: () => context.push(AppRoutes.debugShorebird),
),
],
),
),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.sectionGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Text(
title,
style: AppText.meta.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.mutedForeground,
),
),
),
child,
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({this.child, this.children = const []});
final Widget? child;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
clipBehavior: Clip.antiAlias,
child:
child ??
Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
children[i],
],
],
),
);
}
}
class _Row extends StatelessWidget {
const _Row({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 20, color: cs.foreground),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppText.body.copyWith(
color: cs.foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: AppText.meta.copyWith(
color: cs.mutedForeground,
fontSize: 12.5,
),
),
],
),
),
Icon(Icons.chevron_right, size: 18, color: cs.mutedForeground),
],
),
),
),
);
}
}
@@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/config/shorebird_service.dart';
import '../../../common/router/app_router.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/spacing.dart';
class DebugShorebirdPage extends StatefulWidget {
const DebugShorebirdPage({super.key});
@override
State<DebugShorebirdPage> createState() => _DebugShorebirdPageState();
}
class _DebugShorebirdPageState extends State<DebugShorebirdPage> {
final ShorebirdService _shorebird = ShorebirdService.instance;
late final bool _isUpdaterAvailable;
var _currentTrack = UpdateTrack.stable;
var _isCheckingForUpdates = false;
Patch? _currentPatch;
@override
void initState() {
super.initState();
_isUpdaterAvailable = _shorebird.isAvailable;
_loadCurrentPatch();
}
Future<void> _loadCurrentPatch() async {
try {
await _shorebird.initCurrentPatch();
if (!mounted) return;
setState(() => _currentPatch = _shorebird.currentPatch);
} catch (error) {
debugPrint('Error reading current patch: $error');
}
}
Future<void> _checkForUpdate() async {
if (_isCheckingForUpdates) return;
try {
setState(() => _isCheckingForUpdates = true);
final status = await _shorebird.checkForUpdate(track: _currentTrack);
if (!mounted) return;
switch (status) {
case UpdateStatus.upToDate:
_showNoUpdateAvailableBanner();
break;
case UpdateStatus.outdated:
_showUpdateAvailableBanner();
break;
case UpdateStatus.restartRequired:
_showRestartBanner();
break;
case UpdateStatus.unavailable:
_showUnavailableBanner();
break;
}
} catch (error) {
debugPrint('Error checking for update: $error');
_showErrorBanner(error);
} finally {
if (mounted) {
setState(() => _isCheckingForUpdates = false);
}
}
}
void _showDownloadingBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
const MaterialBanner(
content: Text('正在下载更新...'),
actions: [
SizedBox(height: 14, width: 14, child: CircularProgressIndicator()),
],
),
);
}
void _showUpdateAvailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('当前 ${_currentTrack.name} track 有可用更新。'),
actions: [
TextButton(
onPressed: () async {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
await _downloadUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Download'),
),
],
),
);
}
void _showNoUpdateAvailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('当前 ${_currentTrack.name} track 没有可用更新。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showRestartBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('新的 patch 已准备好,请重启应用。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showUnavailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('当前构建不可用 Shorebird。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showErrorBanner(Object error) {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('检查更新时发生错误:$error'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
Future<void> _downloadUpdate() async {
_showDownloadingBanner();
try {
await _shorebird.downloadUpdate(track: _currentTrack);
if (!mounted) return;
setState(() => _currentPatch = _shorebird.currentPatch);
_showRestartBanner();
} on UpdateException catch (error) {
_showErrorBanner(error.message);
} catch (error) {
_showErrorBanner(error);
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
'Shorebird',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.debug);
}
},
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
children: [
if (!_isUpdaterAvailable) ...[
_Card(
child: Text(
'当前构建未接入 Shorebird。请确认应用是通过 `shorebird release` 生成的 release 包,然后再检查更新与 patch 状态。',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
),
const SizedBox(height: Spacing.cardGap),
],
_Section(
title: '当前状态',
child: _Card(
child: Column(
children: [
_InfoTile(
title: '当前 patch 版本',
value: _currentPatch != null
? '${_currentPatch!.number}'
: '未安装 patch',
),
Divider(height: 1, thickness: 1, color: cs.border),
_InfoTile(
title: '更新器可用',
value: _isUpdaterAvailable ? '' : '',
),
],
),
),
),
_Section(
title: 'Track 选择',
child: _Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'选择要检查和下载的 track。',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 12),
SegmentedButton<UpdateTrack>(
segments: const [
ButtonSegment(
label: Text('Stable'),
value: UpdateTrack.stable,
),
ButtonSegment(
label: Text('Beta'),
value: UpdateTrack.beta,
),
ButtonSegment(
label: Text('Staging'),
value: UpdateTrack.staging,
),
],
selected: {_currentTrack},
onSelectionChanged: (tracks) {
setState(() => _currentTrack = tracks.single);
},
),
],
),
),
),
),
_Section(
title: '操作',
child: _Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: _isCheckingForUpdates
? null
: _checkForUpdate,
child: _isCheckingForUpdates
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('检查更新'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _downloadUpdate,
child: const Text('下载更新'),
),
),
],
),
),
),
),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.sectionGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Text(
title,
style: AppText.meta.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.mutedForeground,
),
),
),
child,
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
clipBehavior: Clip.antiAlias,
child: child,
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({required this.title, required this.value});
final String title;
final String value;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppText.meta.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 4),
Text(
value,
style: AppText.body.copyWith(
color: cs.foreground,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,24 @@
import 'package:fancy_dio_inspector/fancy_dio_inspector.dart';
import 'package:flutter/material.dart';
class FancyDioInspectorPage extends StatelessWidget {
const FancyDioInspectorPage({super.key});
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(
title: const Text('请求'),
leading: Navigator.of(context).canPop()
? IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.chevron_left),
)
: null,
),
body: const FancyDioInspectorView(),
),
);
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/spacing.dart';
class TypographyTestPage extends StatelessWidget {
const TypographyTestPage({super.key});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
const samples = <_Sample>[
_Sample('appTitle', AppText.appTitle),
_Sample('sectionTitle', AppText.sectionTitle),
_Sample('cardTitle', AppText.cardTitle),
_Sample('listTitle', AppText.listTitle),
_Sample('body', AppText.body),
_Sample('meta', AppText.meta),
_Sample('chip', AppText.chip),
_Sample('badge', AppText.badge),
];
return Scaffold(
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
'Typography',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
onPressed: () => context.pop(),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
children: [
for (final sample in samples) ...[
Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
clipBehavior: Clip.antiAlias,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: Spacing.cardGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sample.name,
style: AppText.meta.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 8),
Text('金值 / Jinzhi / Typography', style: sample.style),
],
),
),
],
],
),
);
}
}
class _Sample {
const _Sample(this.name, this.style);
final String name;
final TextStyle style;
}