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
+11
View File
@@ -0,0 +1,11 @@
class AppConfig {
const AppConfig({required this.apiBaseUrl});
final String apiBaseUrl;
factory AppConfig.fromEnvironment() {
const url = String.fromEnvironment(
'JINZHI_API_BASE_URL',
defaultValue: 'https://jinzhi.api.local',
);
return const AppConfig(apiBaseUrl: url);
}
}
+37
View File
@@ -0,0 +1,37 @@
class AppH5Urls {
const AppH5Urls._();
static const baseUrl = String.fromEnvironment(
'JINZHI_H5_BASE_URL',
defaultValue: 'https://jinzhi.h5.local',
);
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
static String get userProtocolUrl => '$baseUrl/beginner/user_agreement.html';
}
class AppH5UrlsHelper {
const AppH5UrlsHelper._();
static String buildUrl(String url, [Map<String, dynamic>? params]) {
if (params == null || params.isEmpty) return url;
final uri = Uri.parse(url);
final merged = <String, String>{
...uri.queryParameters,
...params.map((key, value) => MapEntry(key, value.toString())),
};
return uri.replace(queryParameters: merged).toString();
}
static String buildUrlWithNight(String url, bool isDark) {
if (!isDark) return url;
return buildUrl(url, {'isNight': '1'});
}
static String withCacheBuster(String url) {
final uri = Uri.parse(url);
final params = Map<String, String>.from(uri.queryParameters);
params['_cb'] = DateTime.now().microsecondsSinceEpoch.toString();
return uri.replace(queryParameters: params).toString();
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
class ShorebirdService {
ShorebirdService._();
static final ShorebirdService instance = ShorebirdService._();
final ShorebirdUpdater _updater = ShorebirdUpdater();
Patch? currentPatch;
bool get isAvailable => _updater.isAvailable;
Future<Patch?> readCurrentPatch() => _updater.readCurrentPatch();
Future<void> initCurrentPatch() async {
try {
currentPatch = await _updater.readCurrentPatch();
} catch (error) {
debugPrint('Error reading current patch: $error');
}
}
Future<UpdateStatus> checkForUpdate({
UpdateTrack track = UpdateTrack.stable,
}) {
return _updater.checkForUpdate(track: track);
}
Future<void> downloadUpdate({UpdateTrack track = UpdateTrack.stable}) {
return _updater.update(track: track).then((_) async {
await initCurrentPatch();
});
}
Future<void> checkForUpdateInBackground({
UpdateTrack track = UpdateTrack.stable,
}) async {
if (!isAvailable) return;
try {
await _updater.checkForUpdate(track: track);
} catch (error) {
debugPrint('Shorebird background check failed: $error');
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// lib/common/config/umeng_config.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
class UmengConfig {
// Keys loaded from .env file
static String _androidAppKey =
'6a27b1f46f259537c7b616be'; // Default placeholder
static String _iosAppKey = '6a27b24e6f259537c7b617a8'; // Default placeholder
static String _androidPushSecret = 'xxx'; // Default placeholder
static Future<void> load() async {
await dotenv.load(
fileName: "assets/env/.env",
); // Load from packaged asset path
_androidAppKey = dotenv.env['UMENG_ANDROID_APP_KEY'] ?? _androidAppKey;
_iosAppKey = dotenv.env['UMENG_IOS_APP_KEY'] ?? _iosAppKey;
_androidPushSecret =
dotenv.env['UMENG_ANDROID_PUSH_SECRET'] ?? _androidPushSecret;
}
static String get androidAppKey => _androidAppKey;
static String get iosAppKey => _iosAppKey;
static String get androidPushSecret => _androidPushSecret;
}
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
enum MetalType {
gold('gold', '黄金', '', Color(0xFFB5862A)),
platinum('platinum', '铂金', '', Color(0xFF7D8792)),
silver('silver', '白银', '', Color(0xFF9DA3AA));
const MetalType(this.id, this.label, this.shortLabel, this.color);
final String id;
final String label;
final String shortLabel;
final Color color;
}
+11
View File
@@ -0,0 +1,11 @@
String formatCny(num value) {
final fixed = value.toStringAsFixed(2);
final parts = fixed.split('.');
final whole = parts.first.replaceAllMapped(
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
(match) => '${match[1]},',
);
return '¥$whole.${parts.last}';
}
String formatGram(num value) => '${value.toStringAsFixed(2)}g';
+20
View File
@@ -0,0 +1,20 @@
import 'package:dio/dio.dart';
String? kratosReason(Object e) {
if (e is! DioException) return null;
final body = e.response?.data;
if (body is Map && body['reason'] is String) return body['reason'] as String;
return null;
}
String kratosDisplayMessage(Object e, {required String fallback}) {
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
if (e is DioException) {
final body = e.response?.data;
if (body is Map && body['message'] is String) {
final m = body['message'] as String;
if (m.isNotEmpty) return m;
}
}
return fallback;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../services/device_header_service.dart';
const _kJwtKey = 'jwt_token';
const _storage = FlutterSecureStorage();
Future<void> saveJwt(String token) =>
_storage.write(key: _kJwtKey, value: token);
Future<void> clearJwt() => _storage.delete(key: _kJwtKey);
Future<String?> loadJwt() => _storage.read(key: _kJwtKey);
class AuthInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
await DeviceHeaderService.to.ensureInitialized();
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
final jwt = await loadJwt();
if (jwt != null && jwt.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $jwt';
}
handler.next(options);
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:fancy_dio_inspector/fancy_dio_inspector.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
import 'auth_interceptor.dart';
import 'kratos_error_interceptor.dart';
part 'dio_provider.g.dart';
const _protobufContentType = 'application/x-protobuf';
@Riverpod(keepAlive: true)
AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
@Riverpod(keepAlive: true)
Dio dio(Ref ref) {
final config = ref.watch(appConfigProvider);
final d = Dio(
BaseOptions(
baseUrl: config.apiBaseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
responseType: ResponseType.bytes,
contentType: _protobufContentType,
headers: const {
'Accept': _protobufContentType,
'Content-Type': _protobufContentType,
},
),
);
d.interceptors.add(AuthInterceptor());
d.interceptors.add(KratosErrorInterceptor());
if (!kReleaseMode) {
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
}
d.interceptors.add(FancyDioInterceptor());
return d;
}
@@ -0,0 +1,8 @@
import 'package:dio/dio.dart';
class KratosErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
handler.next(err);
}
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../features/assets/presentation/assets_page.dart';
import '../../features/auth/presentation/delete_account_page.dart';
import '../../features/auth/presentation/login_page.dart';
import '../../features/debug/presentation/debug_info_page.dart';
import '../../features/debug/presentation/debug_page.dart';
import '../../features/debug/presentation/debug_shorebird_page.dart';
import '../../features/debug/presentation/fancy_dio_inspector_page.dart';
import '../../features/debug/presentation/typography_test_page.dart';
import '../../features/market/presentation/market_page.dart';
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 '../services/device_header_service.dart';
import 'tab_shell.dart';
abstract final class AppRoutes {
static const assets = '/assets';
static const market = '/market';
static const news = '/news';
static const profile = '/profile';
static const login = '/login';
static const settings = '/settings';
static const deleteAccount = '/delete-account';
static const deleteAccountCompleted = '/delete-account-completed';
static const debug = '/debug';
static const debugInfo = '/debug/info';
static const debugShorebird = '/debug/shorebird';
static const debugTypography = '/debug/typography';
static const fancyDioInspector = '/debug/fancy_dio_inspector';
static const privacyConsent = '/privacy-consent';
}
final routerProvider = Provider<GoRouter>((ref) {
final startupListenable = ValueNotifier<int>(0);
return GoRouter(
initialLocation: AppRoutes.assets,
refreshListenable: startupListenable,
redirect: (context, state) {
final loc = state.matchedLocation;
final onConsent = loc == AppRoutes.privacyConsent;
final showStartup =
DeviceHeaderService.to.privacyConsentRequired ||
DeviceHeaderService.to.iosStartupRequired;
if (showStartup) {
if (!onConsent) return AppRoutes.privacyConsent;
} else if (onConsent) {
return AppRoutes.assets;
}
return null;
},
routes: [
ShellRoute(
builder: (context, state, child) =>
TabShell(location: state.matchedLocation, child: child),
routes: [
GoRoute(
path: AppRoutes.assets,
builder: (ctx, s) => const AssetsPage(),
),
GoRoute(
path: AppRoutes.market,
builder: (ctx, s) => const MarketPage(),
),
GoRoute(path: AppRoutes.news, builder: (ctx, s) => const NewsPage()),
GoRoute(
path: AppRoutes.profile,
builder: (ctx, s) => const ProfilePage(),
),
],
),
GoRoute(path: AppRoutes.debug, builder: (_, s) => const DebugPage()),
GoRoute(
path: AppRoutes.login,
builder: (_, s) => LoginPage(next: s.uri.queryParameters['next']),
),
GoRoute(
path: AppRoutes.settings,
builder: (_, s) => const SettingsPage(),
),
GoRoute(
path: AppRoutes.deleteAccount,
builder: (_, s) => const DeleteAccountPage(),
),
GoRoute(
path: AppRoutes.deleteAccountCompleted,
builder: (_, s) => const DeleteAccountSuccessPage(),
),
GoRoute(
path: AppRoutes.debugInfo,
builder: (_, s) => const DebugInfoPage(),
),
GoRoute(
path: AppRoutes.debugShorebird,
builder: (_, s) => const DebugShorebirdPage(),
),
GoRoute(
path: AppRoutes.debugTypography,
builder: (_, s) => const TypographyTestPage(),
),
GoRoute(
path: AppRoutes.fancyDioInspector,
builder: (_, s) => const FancyDioInspectorPage(),
),
GoRoute(
path: AppRoutes.privacyConsent,
builder: (_, s) => const PrivacyConsentPage(),
),
],
);
});
+7
View File
@@ -0,0 +1,7 @@
String tabFallbackForPath(String path) {
if (path.startsWith('/assets')) return '/assets';
if (path.startsWith('/market')) return '/market';
if (path.startsWith('/news')) return '/news';
if (path.startsWith('/profile')) return '/profile';
return '/assets';
}
+255
View File
@@ -0,0 +1,255 @@
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 '../theme/app_icons.dart';
import '../widgets/adaptive.dart';
import 'app_router.dart';
class TabShell extends StatefulWidget {
const TabShell({super.key, required this.child, required this.location});
final Widget child;
final String location;
@override
State<TabShell> createState() => _TabShellState();
}
class _TabShellState extends State<TabShell> {
static const double _compactHeaderThreshold = 34;
bool _showCompactHeader = false;
static const _tabs = ['/assets', '/market', '/news', '/profile'];
int get _index =>
_tabs.indexWhere((t) => widget.location.startsWith(t)).clamp(0, 3);
@override
void didUpdateWidget(covariant TabShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.location != widget.location && _showCompactHeader) {
_showCompactHeader = false;
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final header = _headerForPath(widget.location);
final canPop = GoRouter.of(context).canPop();
return Scaffold(
backgroundColor: cs.background,
resizeToAvoidBottomInset: false,
body: Stack(
children: [
Scaffold(
backgroundColor: cs.background,
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.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),
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: Stack(children: [Positioned.fill(child: widget.child)]),
),
),
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 < _tabs.length; i++)
Expanded(
child: _TabButton(
icon: i == _index
? _filledIcons[i]
: _outlineIcons[i],
label: _labels[i],
selected: i == _index,
onTap: () => context.go(_tabs[i]),
),
),
],
),
),
),
),
),
],
),
),
),
if (!kReleaseMode)
Positioned(
right: 16,
bottom: 88,
child: FilledButton(
onPressed: () => context.push(AppRoutes.fancyDioInspector),
style: FilledButton.styleFrom(
backgroundColor: cs.primary,
foregroundColor: cs.primaryForeground,
minimumSize: const Size(60, 60),
shape: const CircleBorder(),
padding: EdgeInsets.zero,
),
child: const Text('请求'),
),
),
],
),
);
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) {
return false;
}
final next = notification.metrics.pixels > _compactHeaderThreshold;
if (next != _showCompactHeader && mounted) {
setState(() => _showCompactHeader = next);
}
return false;
}
}
class _TabButton extends StatelessWidget {
const _TabButton({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final color = selected ? cs.foreground : cs.mutedForeground;
return InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 22, color: color),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: color,
letterSpacing: 0,
),
),
],
),
);
}
}
const _filledIcons = [
AppIcons.assetsFill,
AppIcons.marketFill,
AppIcons.newsFill,
AppIcons.meFill,
];
const _outlineIcons = [
AppIcons.assetsLine,
AppIcons.marketLine,
AppIcons.newsLine,
AppIcons.meLine,
];
const _labels = ['资产', '行情', '资讯', '我的'];
_ShellHeader _headerForPath(String path) {
return switch (path) {
'/market' => const _ShellHeader('行情', ''),
'/news' => const _ShellHeader('资讯', ''),
'/profile' => const _ShellHeader('我的'),
_ => const _ShellHeader('金值', ''),
};
}
class _ShellHeader {
const _ShellHeader(this.title, [this.subtitle]);
final String title;
final String? subtitle;
}
@@ -0,0 +1,274 @@
import 'dart:io' show Platform;
import 'package:android_id/android_id.dart';
import 'package:android_oaid/android_oaid.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_paid/flutter_paid.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:platform_metadata/platform_metadata.dart';
import 'package:uuid/uuid.dart';
import '../storage/storage_keys.dart';
import '../storage/storage_service.dart';
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
/// AuthInterceptor 每个请求注入 commonHeaders() + platformIdentityHeaders()
/// token 另由拦截器注入。空值 header 一律过滤。
///
/// 注:`User-Agent` 这里不表示浏览器 UA,而是沿用后端请求头契约中的平台标识,
/// 值同:iOS/Android/Web/Other。
///
/// 广告/设备标识(OAID/IMEI/MAC/IDFA/Paid/DeviceToken)由各自 SDK/ATT 流程
/// 写入 StorageService,这里读出注入;Android 标识仅在用户同意隐私后采集。
class DeviceHeaderService {
DeviceHeaderService._();
static final DeviceHeaderService to = DeviceHeaderService._();
static const _storage = FlutterSecureStorage();
static const _kDeviceIdKey = 'device_id';
// static const _platformMetadataChannel = MethodChannel('platform_metadata');
// 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxxAndroid 其次读 manifest
static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
String _deviceId = '';
String _version = '';
String _build = '';
String _deviceModel = '';
String _userChannel = '';
String _iosIdfv = '';
String _iosPaid = '';
String _androidId = '';
String _androidOaid = '';
String _androidImei = '';
String _androidMac = '';
Future<void>? _baseInitialization;
Future<void>? _identityInitialization;
Future<void> ensureInitialized() async {
_baseInitialization ??= _initializeBase();
await _baseInitialization;
// Align with tradingagents-flutter-app: Android only initializes device
// information after the user has accepted the privacy policy.
if (!kIsWeb &&
Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
return;
}
_identityInitialization ??= _initializeIdentity();
await _identityInitialization;
}
Future<void> _initializeBase() async {
try {
final info = await PackageInfo.fromPlatform();
_version = info.version;
_build = info.buildNumber;
} catch (_) {}
_userChannel = await _resolveUserChannel();
}
Future<void> _initializeIdentity() async {
_deviceId = await _resolveDeviceId();
_androidOaid = StorageService.to.getString(storageAndroidOaid) ?? '';
_androidImei = StorageService.to.getString(storageAndroidImei) ?? '';
_androidMac = StorageService.to.getString(storageAndroidMac) ?? '';
_androidId = StorageService.to.getString(storageAndroidId) ?? '';
_iosPaid = StorageService.to.getString(storageIosPaid) ?? '';
await _resolvePlatformIdentity();
}
/// X-Device-IdiOS 优先 IDFVper-vendor 稳定),否则 UUIDv4
/// 首次生成后写入 secure_storage,后续复用。
Future<String> _resolveDeviceId() async {
final stored = await _storage.read(key: _kDeviceIdKey);
if (stored != null && stored.isNotEmpty) return stored;
var id = '';
if (!kIsWeb && Platform.isIOS) {
id = await _resolveIdfv();
}
if (id.isEmpty) id = const Uuid().v4();
await _storage.write(key: _kDeviceIdKey, value: id);
return id;
}
Future<String> _resolveIdfv() async {
try {
final idfv = (await DeviceInfoPlugin().iosInfo).identifierForVendor;
return idfv ?? '';
} catch (_) {
return '';
}
}
/// 设备型号 + 平台身份标识。Android 标识需用户同意隐私后才采集。
Future<void> _resolvePlatformIdentity() async {
if (kIsWeb) return;
try {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
_deviceModel = (await plugin.androidInfo).model;
await _collectAndroidIdIfAgreed();
await _collectAndroidOaidIfAgreed();
await _collectAndroidImeiIfAgreed();
await _collectAndroidMacIfAgreed();
} else if (Platform.isIOS) {
_deviceModel = (await plugin.iosInfo).utsname.machine;
_iosIdfv = await _resolveIdfv();
await _collectIosPaidIfMissing();
}
} catch (_) {}
}
Future<void> _collectAndroidIdIfAgreed() async {
if (_androidId.isNotEmpty) return;
if (!StorageService.to.getBool(storagePrivacyAgreed)) return; // 隐私同意后才采集
try {
_androidId = await const AndroidId().getId() ?? '';
if (_androidId.isNotEmpty) {
await StorageService.to.setString(storageAndroidId, _androidId);
}
} catch (_) {}
}
Future<void> _collectAndroidOaidIfAgreed() async {
if (_androidOaid.isNotEmpty) return;
if (!StorageService.to.getBool(storagePrivacyAgreed)) return;
try {
_androidOaid = await const AndroidOaidId().getOaid() ?? '';
if (_androidOaid.isNotEmpty) {
await StorageService.to.setString(storageAndroidOaid, _androidOaid);
}
} catch (_) {}
}
Future<void> _collectAndroidImeiIfAgreed() async {
if (_androidImei.isNotEmpty) return;
if (!StorageService.to.getBool(storagePrivacyAgreed)) return;
try {
_androidImei = await const AndroidId().getImei() ?? '';
if (_androidImei.isNotEmpty) {
await StorageService.to.setString(storageAndroidImei, _androidImei);
}
} catch (_) {}
}
Future<void> _collectAndroidMacIfAgreed() async {
if (_androidMac.isNotEmpty) return;
if (!StorageService.to.getBool(storagePrivacyAgreed)) return;
try {
_androidMac = await const AndroidId().getMaAddress() ?? '';
if (_androidMac.isNotEmpty) {
await StorageService.to.setString(storageAndroidMac, _androidMac);
}
} catch (_) {}
}
Future<void> _collectIosPaidIfMissing() async {
if (_iosPaid.isNotEmpty) return;
try {
_iosPaid = await FlutterPaid().paid() ?? '';
if (_iosPaid.isNotEmpty) {
await StorageService.to.setString(storageIosPaid, _iosPaid);
}
} catch (_) {}
}
/// 用户同意隐私协议后调用。设备信息由随后调用的 ensureInitialized 补采。
Future<void> markPrivacyAgreed() async {
await StorageService.to.setBool(storagePrivacyAgreed, true);
}
/// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
bool get privacyConsentRequired =>
!kIsWeb &&
Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed);
/// iOS 首次启动页:仅在首次安装后展示一次。
bool get iosStartupRequired =>
!kIsWeb &&
Platform.isIOS &&
!StorageService.to.getBool(storageIosStartupSeen);
Future<void> markIosStartupSeen() async {
if (kIsWeb || !Platform.isIOS) return;
await StorageService.to.setBool(storageIosStartupSeen, true);
}
String get deviceId => _deviceId;
// User-Platform:平台标识(替代研值的 User-Agent)。
String _platform() {
if (kIsWeb) return 'Web';
if (Platform.isIOS) return 'iOS';
if (Platform.isAndroid) return 'Android';
return 'Other';
}
Future<String> _resolveUserChannel() async {
if (_umengChannel.isNotEmpty) return _umengChannel;
if (!kIsWeb && Platform.isAndroid) {
try {
final manifestChannel = await PlatformMetadata.getMetaDataValue(
'UMENG_CHANNEL',
);
if (manifestChannel != null && manifestChannel.isNotEmpty) {
return manifestChannel;
}
} catch (_) {}
}
if (kIsWeb) return 'web';
if (Platform.isIOS) return 'ios';
if (Platform.isAndroid) return 'android';
return 'other';
}
String _s(String key) => StorageService.to.getString(key) ?? '';
/// 公共 header(不含 token)。11
Map<String, String> commonHeaders() => _compact({
'X-Device-Id': _deviceId,
'User-Version': _version,
'User-BuildID': _build,
'User-Platform': _platform(),
'User-Channel': _userChannel,
'User-DeviceType': _deviceModel,
'User-DeviceToken': privacyConsentRequired ? '' : _s(storageDeviceToken),
});
/// 平台身份 / 广告标识 header(按平台)。
Map<String, String> platformIdentityHeaders() {
if (kIsWeb) return const {};
if (Platform.isAndroid) {
if (!StorageService.to.getBool(storagePrivacyAgreed)) return const {};
return _compact({
'User-Android-ID': _androidId,
'User-Android-OAID': _androidOaid,
'User-Android-IMEI': _androidImei,
'User-Android-Mac': _androidMac,
});
}
if (Platform.isIOS) {
return _compact({
'User-iOS-IDFV': _iosIdfv,
'User-iOS-IDFA': _s(storageIosIdfa),
'User-iOS-Paid': _iosPaid,
});
}
return const {};
}
}
Map<String, String> _compact(Map<String, String> headers) {
final out = <String, String>{};
headers.forEach((k, v) {
if (v.trim().isNotEmpty) out[k] = v;
});
return out;
}
+19
View File
@@ -0,0 +1,19 @@
const storageThemeMode = 'theme_mode';
const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控)
const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示
const storageJinzhiHoldings = 'jinzhi_holdings';
const storageJinzhiAmountHidden = 'jinzhi_amount_hidden';
const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts';
const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
// header;未写入则为空、注入时被过滤。
const storageDeviceToken = 'device_token'; // 推送 token
const storageAndroidId = 'android_id';
const storageAndroidOaid = 'android_oaid';
const storageAndroidImei = 'android_imei';
const storageAndroidMac = 'android_mac';
const storageIosIdfa = 'ios_idfa';
const storageIosPaid = 'ios_paid';
+20
View File
@@ -0,0 +1,20 @@
import 'package:shared_preferences/shared_preferences.dart';
class StorageService {
StorageService._(this._prefs);
final SharedPreferences _prefs;
static StorageService? _instance;
static StorageService get to => _instance!;
static Future<StorageService> get instance async {
if (_instance != null) return _instance!;
final prefs = await SharedPreferences.getInstance();
_instance = StorageService._(prefs);
return _instance!;
}
bool getBool(String key, {bool def = false}) => _prefs.getBool(key) ?? def;
Future<void> setBool(String key, bool v) => _prefs.setBool(key, v);
String? getString(String key) => _prefs.getString(key);
Future<void> setString(String key, String v) => _prefs.setString(key, v);
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter_remix/flutter_remix.dart';
// flutter_remix 0.0.3: icon class is FlutterRemix
class AppIcons {
static const assetsFill = FlutterRemix.wallet_3_fill;
static const assetsLine = FlutterRemix.wallet_3_line;
static const marketFill = FlutterRemix.line_chart_fill;
static const marketLine = FlutterRemix.line_chart_line;
static const newsFill = FlutterRemix.article_fill;
static const newsLine = FlutterRemix.article_line;
static const meFill = FlutterRemix.user_3_fill;
static const meLine = FlutterRemix.user_3_line;
static const arrowRight = FlutterRemix.arrow_right_s_line;
static const arrowLeft = FlutterRemix.arrow_left_s_line;
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/widgets.dart';
/// 字号刻度常量。
/// 颜色在用处通过 ShadTheme.of(context).colorScheme 取,此处只定义尺寸/粗细/行高。
abstract class AppText {
/// App 大标题: 34px / w800 / lh 1.15
static const TextStyle appTitle = TextStyle(
fontSize: 34,
fontWeight: FontWeight.w800,
height: 1.15,
);
/// 区块标题: 22px / w700
static const TextStyle sectionTitle = TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
);
/// 卡片标题: 19px / w620 / lh 1.4
static const TextStyle cardTitle = TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600, // 最接近 w620
height: 1.4,
);
/// 列表标题: 16.5px / w600
static const TextStyle listTitle = TextStyle(
fontSize: 16.5,
fontWeight: FontWeight.w600,
);
/// 正文: 15px / w400 / lh 1.6
static const TextStyle body = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1.6,
);
/// meta 信息: 13px / w400
static const TextStyle meta = TextStyle(
fontSize: 13,
fontWeight: FontWeight.w400,
);
/// chip 筛选: 15px / w500
static const TextStyle chip = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
);
/// badge 标签: 12px / w500
static const TextStyle badge = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.5,
);
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/widgets.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
const jinzhiLightScheme = ShadColorScheme(
background: Color(0xFFFBFAF7),
foreground: Color(0xFF1A1A17),
card: Color(0xFFFFFFFF),
cardForeground: Color(0xFF1A1A17),
popover: Color(0xFFFFFFFF),
popoverForeground: Color(0xFF1A1A17),
primary: Color(0xFFB5862A),
primaryForeground: Color(0xFFFFFFFF),
secondary: Color(0xFFF6ECD6),
secondaryForeground: Color(0xFF7A5B12),
muted: Color(0xFFF1EFE9),
mutedForeground: Color(0xFF8A8780),
accent: Color(0xFFF6ECD6),
accentForeground: Color(0xFF7A5B12),
destructive: Color(0xFFC0392B),
destructiveForeground: Color(0xFFFFFFFF),
border: Color(0xFFECEAE3),
input: Color(0xFFECEAE3),
ring: Color(0xFFB5862A),
selection: Color(0xFF1A1A17),
);
/// Hero 卡描边 = demo `--brand-soft-border`(比 brand-soft 填充更深一档的绿)。
/// ShadColorScheme 无对应 slot,单列于此(色值仍集中在 theme,widget 不写 hex);
/// widget 按 brightness 取值。light=oklch(0.9 0.07 128)、dark=oklch(0.42 0.07 131)。
const jinzhiBrandSoftBorderLight = Color(0xFFE3D6B4);
const jinzhiBrandSoftBorderDark = Color(0xFF5E4A22);
Color brandSoftBorder(Brightness b) => b == Brightness.dark
? jinzhiBrandSoftBorderDark
: jinzhiBrandSoftBorderLight;
/// Herobrand-soft 绿底)上的副/弱文本色。
/// 规范:次级文本是**中性 muted**,不挪用语义色(accentForeground 是链接/强调专用)。
/// 浅绿底上灰 muted(#8E8E93) 对比不足,故用 foreground 降透明的**中性深灰**
/// (叠在绿底上自然带轻微绿调、和谐且达 AA);深色底仍用灰 muted(已够对比)。
Color brandSoftMutedForeground(Brightness b, ShadColorScheme cs) =>
b == Brightness.dark
? cs.mutedForeground
: cs.foreground.withValues(alpha: 0.7);
/// 危险警示卡 soft-tintdemo `.da-warn`,与 brand-soft 同语言的 destructive 变体)。
const jinzhiDestructiveSoftLight = Color(0xFFFBEDEA);
const jinzhiDestructiveSoftBorderLight = Color(0xFFE8C7BF);
const jinzhiDestructiveSoftDark = Color(0x1AEF4444);
const jinzhiDestructiveSoftBorderDark = Color(0x52EF4444);
Color destructiveSoft(Brightness b) => b == Brightness.dark
? jinzhiDestructiveSoftDark
: jinzhiDestructiveSoftLight;
Color destructiveSoftBorder(Brightness b) => b == Brightness.dark
? jinzhiDestructiveSoftBorderDark
: jinzhiDestructiveSoftBorderLight;
const jinzhiDarkScheme = ShadColorScheme(
background: Color(0xFF0F0F0F),
foreground: Color(0xFFF0F0F0),
card: Color(0xFF1A1A1A),
cardForeground: Color(0xFFF0F0F0),
popover: Color(0xFF1A1A1A),
popoverForeground: Color(0xFFF0F0F0),
primary: Color(0xFFD9B45D),
primaryForeground: Color(0xFF1A1A17),
secondary: Color(0xFF27272A),
secondaryForeground: Color(0xFFF0F0F0),
muted: Color(0xFF262626),
mutedForeground: Color(0xFFA1A1AA),
accent: Color(0xFF3A2A0D),
accentForeground: Color(0xFFD9B45D),
destructive: Color(0xFFEF4444),
destructiveForeground: Color(0xFFFFFFFF),
border: Color(0x1FFFFFFF),
input: Color(0x26FFFFFF),
ring: Color(0xFFD9B45D),
selection: Color(0xFFF0F0F0),
);
+10
View File
@@ -0,0 +1,10 @@
class Spacing {
static const page = 20.0;
static const cardPadding = 18.0;
static const cardGap = 14.0;
static const sectionGap = 30.0;
static const radiusBase = 7.2; // 0.45rem --radius
static const radiusBadge = 3.2; // --radius-sm (badge / tag)
static const radiusBtn = 5.2; // --radius-md (button / input)
static const radiusCard = 11.2; // --radius-xl (card / hero 容器)
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../storage/storage_service.dart';
import '../storage/storage_keys.dart';
part 'theme_controller.g.dart';
@riverpod
class ThemeModeController extends _$ThemeModeController {
@override
ThemeMode build() {
final v = StorageService.to.getString(storageThemeMode);
return switch (v) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
}
Future<void> set(ThemeMode mode) async {
state = mode;
await StorageService.to.setString(storageThemeMode, mode.name);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../widgets/app_toast.dart';
import '../widgets/outbound_service_sheet.dart';
Future<void> openExternalUrl(
BuildContext context,
String url, {
String title = '即将打开外部服务',
String description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
String fallbackToast = '链接即将上线',
}) async {
if (url.isEmpty) {
toastInfo(msg: fallbackToast);
return;
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) {
toastInfo(msg: fallbackToast);
return;
}
final ok = await showOutboundServiceSheet(
context,
title: title,
description: description,
);
if (ok != true || !context.mounted) return;
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched && context.mounted) {
toastInfo(msg: fallbackToast);
}
}
+53
View File
@@ -0,0 +1,53 @@
/// 中西文混排自动加间隙(W3C clreq §3.2.2)。
///
/// 规范要求:横排时汉字与西文字母/数字之间留约 1/4 em 间隙。CSS 有
/// `text-autospace`Flutter 没有对应能力,因此在文本层插入
/// U+2009 THIN SPACE(约 1/5 em,视觉接近规范建议,且远比全角空格克制)。
///
/// 规则(与 pangu 习惯一致):
/// - 汉字 ↔ 字母/数字 相邻 → 插 thin space(「下降2.2%」→「下降 2.2%」);
/// - 西文 run 末尾的 % ‰ 视作 run 的一部分(「2.2%的降幅」→「2.2% 的降幅」);
/// - 全角标点(,。:等)自带空隙,不加;
/// - markdown 版本额外处理强调/行内码边界(「至**138美元**」→「至 **138美元**」),
/// 并跳过 fenced code block;表格管道符、链接 URL 天然不受影响。
library;
const String _thin = '\u2009'; // THIN SPACE
// CJK 统一表意文字(基本区 + 扩展 A + 兼容区)
const String _han = r'㐀-䶿一-鿿豈-﫿';
// 西文边界字符(字母/数字);西文侧在右边界额外允许 % ‰ 收尾
const String _west = r'A-Za-z0-9';
final RegExp _hanWest = RegExp('([$_han])([$_west])');
final RegExp _westHan = RegExp('([$_west%‰])([$_han])');
// 强调/行内码标记夹在边界中间:汉**西 / 西**汉
final RegExp _hanMarkWest = RegExp('([$_han])(\\*{1,3}|`)([$_west])');
final RegExp _westMarkHan = RegExp('([$_west%‰])(\\*{1,3}|`)([$_han])');
/// 纯文本版:用于标题、副题、简介等 Text 控件。
String cjkAutoSpace(String text) {
var s = text;
s = s.replaceAllMapped(_hanWest, (m) => '${m[1]}$_thin${m[2]}');
s = s.replaceAllMapped(_westHan, (m) => '${m[1]}$_thin${m[2]}');
return s;
}
/// markdown 版:跳过 fenced code block,并处理强调/行内码边界。
String cjkMarkdownAutoSpace(String md) {
final lines = md.split('\n');
var inFence = false;
for (var i = 0; i < lines.length; i++) {
final t = lines[i].trimLeft();
if (t.startsWith('```') || t.startsWith('~~~')) {
inFence = !inFence;
continue;
}
if (inFence) continue;
var s = lines[i];
s = s.replaceAllMapped(_hanMarkWest, (m) => '${m[1]}$_thin${m[2]}${m[3]}');
s = s.replaceAllMapped(_westMarkHan, (m) => '${m[1]}${m[2]}$_thin${m[3]}');
lines[i] = cjkAutoSpace(s);
}
return lines.join('\n');
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
import '../theme/spacing.dart';
/// iPad 响应式布局:
/// 宽 ≥ [breakpoint] 进入平板布局——屏边距 36、内容列统一限宽 920 居中
/// `padding: max(36, (宽-920)/2)`)、卡片列表两列(列距 18、行内等高)、
/// 底部 tab 行限宽 600 居中。手机布局完全不变。
abstract class Adaptive {
/// 平板断点(iPhone 最宽 ~440iPad 11″ 竖屏 834 起)
static const double breakpoint = 700;
/// 平板屏边距(demo `--pad-screen: 36px`
static const double padTablet = 36;
/// 内容列限宽(demo 所有页统一 920 居中)
static const double contentMaxWidth = 920;
/// 两列列表的列间距(demo `column-gap: 18px`
static const double columnGap = 18;
/// 底部 tab 行限宽(demo `.nav .tabs{max-width:600px}`
static const double tabsMaxWidth = 600;
static bool isTablet(BuildContext context) =>
MediaQuery.sizeOf(context).width >= breakpoint;
/// 屏水平边距:手机 [Spacing.page];平板 `max(36, (宽-920)/2)`
/// 即内容超 920 时左右对称留白实现居中限宽。
static double hPad(BuildContext context) {
final w = MediaQuery.sizeOf(context).width;
if (w < breakpoint) return Spacing.page;
return max(padTablet, (w - contentMaxWidth) / 2);
}
static EdgeInsets screenPadding(
BuildContext context, {
double top = 0,
double bottom = 0,
}) {
final h = hPad(context);
return EdgeInsets.fromLTRB(h, top, h, bottom);
}
}
/// 卡片列表自适应 sliver:手机单列(卡间距 [Spacing.cardGap]);
/// 平板两列——卡片两两成行、行内等高(IntrinsicHeight + stretch
/// 对应 demo CSS grid `1fr 1fr` 的等高行行为)。
SliverList adaptiveCardSliver(
BuildContext context, {
required int itemCount,
required Widget Function(BuildContext, int) itemBuilder,
double rowGap = Spacing.cardGap,
}) {
if (!Adaptive.isTablet(context)) {
return SliverList.separated(
itemCount: itemCount,
separatorBuilder: (_, _) => SizedBox(height: rowGap),
itemBuilder: itemBuilder,
);
}
final rows = (itemCount + 1) ~/ 2;
return SliverList.separated(
itemCount: rows,
separatorBuilder: (_, _) => SizedBox(height: rowGap),
itemBuilder: (c, r) {
final rightIndex = r * 2 + 1;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: itemBuilder(c, r * 2)),
const SizedBox(width: Adaptive.columnGap),
Expanded(
child: rightIndex < itemCount
? itemBuilder(c, rightIndex)
: const SizedBox(),
),
],
),
);
},
);
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../theme/app_text.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
enum AppStateButtonStyle { filled, outline }
class AppStateCard extends StatelessWidget {
const AppStateCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.buttonLabel,
this.onButtonPressed,
this.buttonStyle = AppStateButtonStyle.outline,
});
final IconData icon;
final String title;
final String subtitle;
final String? buttonLabel;
final VoidCallback? onButtonPressed;
final AppStateButtonStyle buttonStyle;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final hasAction = buttonLabel != null && onButtonPressed != null;
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 104,
height: 104,
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.secondary,
shape: BoxShape.circle,
),
child: Icon(icon, size: 46, color: cs.mutedForeground),
),
const SizedBox(height: 22),
Text(
title,
textAlign: TextAlign.center,
style: AppText.cardTitle.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
color: cs.foreground,
height: 1.2,
),
),
const SizedBox(height: 10),
Text(
subtitle,
textAlign: TextAlign.center,
style: AppText.body.copyWith(
fontSize: 15,
color: cs.mutedForeground,
height: 1.5,
),
),
if (hasAction) ...[
const SizedBox(height: 20),
SizedBox(
height: 42,
child: buttonStyle == AppStateButtonStyle.filled
? FilledButton(
onPressed: onButtonPressed,
style: FilledButton.styleFrom(
backgroundColor: cs.foreground,
foregroundColor: cs.background,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 18),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: Text(buttonLabel!),
)
: OutlinedButton(
onPressed: onButtonPressed,
style: OutlinedButton.styleFrom(
foregroundColor: cs.foreground,
side: BorderSide(color: cs.border),
backgroundColor: cs.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 18),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: Text(buttonLabel!),
),
),
],
],
),
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
// void showAppToast(BuildContext context, String message) {
// ScaffoldMessenger.of(context)
// ..hideCurrentSnackBar()
// ..showSnackBar(
// SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
// );
// }
Future<bool?> toastInfo({
required String msg,
Color backgroundColor = const Color(0xCC111111),
Color textColor = Colors.white,
}) {
return Fluttertoast.showToast(
msg: msg,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: backgroundColor,
textColor: textColor,
fontSize: 16,
);
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 圆形返回钮(对齐 demo `.back`38×38 圆 + 1px 描边 + chevron)。
/// 左对齐到 12pxdemo `.dhead` padding-left),用作各页 AppBar 的 leading。
class CircleBackButton extends StatelessWidget {
const CircleBackButton({
super.key,
this.onTap,
this.fallbackLocation = '/assets',
});
final VoidCallback? onTap;
final String fallbackLocation;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap:
onTap ??
() {
if (context.canPop()) {
context.pop();
} else {
context.go(fallbackLocation);
}
},
child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: cs.border, width: 1),
),
child: Icon(
FlutterRemix.arrow_left_s_line,
size: 22,
color: cs.foreground,
),
),
),
),
);
}
}
+254
View File
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_text.dart';
import '../theme/spacing.dart';
// ─────────────────────────────────────────────────────────────────────────
// 详情/主页通用卡片语言。
// ─────────────────────────────────────────────────────────────────────────
/// 白底描边卡片(demo `.mcard`)。
class DetailCard extends StatelessWidget {
const DetailCard({super.key, required this.child, this.color});
final Widget child;
/// 卡片底色,默认白 `card`。
final Color? color;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.cardPadding),
decoration: BoxDecoration(
color: color ?? cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: child,
);
}
}
/// 卡片头部:编号圆角块(或图标)+ 标题(demo `.mh > .mnum + h2`)。
class DetailSectionHeader extends StatelessWidget {
const DetailSectionHeader({
super.key,
this.number,
this.icon,
required this.title,
});
final int? number;
final IconData? icon;
final String title;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Row(
children: [
Container(
width: 26,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.accent,
borderRadius: BorderRadius.circular(7),
),
child: number != null
? Text(
'$number',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.accentForeground,
),
)
: Icon(icon, size: 15, color: cs.accentForeground),
),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: AppText.listTitle.copyWith(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
),
],
);
}
}
/// 徽标(demo `.badge` secondary / outline / 实底)。
class DetailBadge extends StatelessWidget {
const DetailBadge(
this.label, {
super.key,
this.bg,
this.fg,
this.outline = false,
this.icon,
});
final String label;
final Color? bg;
final Color? fg;
final bool outline;
final IconData? icon;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: outline ? Colors.transparent : (bg ?? cs.secondary),
borderRadius: BorderRadius.circular(Spacing.radiusBadge),
// 实底也带 1px 透明描边,与 outline 徽标等高等宽
border: outline
? Border.all(color: cs.border)
: Border.all(color: Colors.transparent),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 10,
color: outline ? cs.mutedForeground : (fg ?? cs.foreground),
),
const SizedBox(width: 3),
],
Text(
label,
style: AppText.badge.copyWith(
color: outline ? cs.mutedForeground : (fg ?? cs.foreground),
),
),
],
),
);
}
}
/// 键值行(demo `.crow > .cl + .cv`)。value 可选链接样式 + 右上箭头,点击回调。
class DetailKeyValueRow extends StatelessWidget {
const DetailKeyValueRow({
super.key,
required this.label,
required this.value,
this.link = false,
this.onTap,
});
final String label;
final String value;
final bool link;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final valueWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
value,
textAlign: TextAlign.right,
style: AppText.meta.copyWith(
fontSize: 13.5,
// 链接用深绿 accentForegroundlime 只做底色,不做文字色)
color: link ? cs.accentForeground : cs.foreground,
fontWeight: FontWeight.w500,
),
),
),
if (link) ...[
const SizedBox(width: 3),
Icon(
FlutterRemix.external_link_line,
size: 13,
color: cs.accentForeground,
),
],
],
);
return Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: cs.border)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppText.meta.copyWith(
fontSize: 13.5,
color: cs.mutedForeground,
),
),
const SizedBox(width: 14),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: link && onTap != null
? GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: valueWidget,
)
: valueWidget,
),
),
],
),
);
}
}
/// 合规提示条(demo `.disc`:⚠ 图标 + 灰字)。
class DetailDisclaimer extends StatelessWidget {
const DetailDisclaimer({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: cs.secondary,
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
FlutterRemix.error_warning_line,
size: 15,
color: cs.mutedForeground,
),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: AppText.meta.copyWith(
fontSize: 12,
color: cs.mutedForeground,
height: 1.5,
),
),
),
],
),
);
}
}
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_text.dart';
import '../theme/spacing.dart';
Future<bool?> showLoginRequiredDialog(
BuildContext context, {
required String title,
required String description,
String cancelLabel = '取消',
String confirmLabel = '去登录',
}) {
final cs = ShadTheme.of(context).colorScheme;
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (ctx) => Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.background,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(Spacing.radiusCard),
topRight: Radius.circular(Spacing.radiusCard),
),
border: Border.all(color: cs.border),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: cs.border,
borderRadius: BorderRadius.circular(9999),
),
),
),
Text(
title,
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 8),
Text(
description,
style: AppText.body.copyWith(
fontSize: 13,
height: 1.6,
color: cs.mutedForeground,
),
),
const SizedBox(height: 22),
_LoginSheetButton(
label: confirmLabel,
primary: true,
onTap: () => Navigator.of(ctx).pop(true),
),
const SizedBox(height: 12),
_LoginSheetButton(
label: cancelLabel,
primary: false,
onTap: () => Navigator.of(ctx).pop(false),
),
],
),
),
),
);
}
class _LoginSheetButton extends StatelessWidget {
const _LoginSheetButton({
required this.label,
required this.primary,
required this.onTap,
});
final String label;
final bool primary;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: primary ? cs.primary : cs.secondary,
borderRadius: BorderRadius.circular(12),
border: primary ? null : Border.all(color: cs.border),
),
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: primary ? cs.primaryForeground : cs.foreground,
),
),
),
);
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// Keep markdown horizontal rules subtle so backend `---` separators read as
/// a light divider instead of a heavy line.
MarkdownStyleSheet withMarkdownHairlineRule(
MarkdownStyleSheet base,
Color ruleColor,
) {
return base.copyWith(
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(color: ruleColor.withValues(alpha: 0.85), width: 0.5),
),
),
);
}
MarkdownStyleSheet appMarkdownStyle(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return withMarkdownHairlineRule(
MarkdownStyleSheet(
// 正文:CJK 适宜行高 1.75,块间统一节奏
p: TextStyle(fontSize: 14.5, height: 1.75, color: cs.foreground),
pPadding: EdgeInsets.zero,
blockSpacing: 14,
// 标题层级 + 上方留白形成分节(中文排版重段落分隔)
h1: TextStyle(
fontSize: 20,
height: 1.35,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
h1Padding: const EdgeInsets.only(top: 6, bottom: 2),
h2: TextStyle(
fontSize: 17,
height: 1.4,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
h2Padding: const EdgeInsets.only(top: 16, bottom: 2),
h3: TextStyle(
fontSize: 15,
height: 1.45,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
h3Padding: const EdgeInsets.only(top: 10),
// 列表:缩进 + 项目符号对齐正文
listIndent: 22,
listBullet: TextStyle(fontSize: 14.5, height: 1.75, color: cs.foreground),
listBulletPadding: const EdgeInsets.only(right: 6),
// 强调:加粗醒目;中文不用斜体(合成斜体难看),em 走常规体
strong: TextStyle(fontWeight: FontWeight.w700, color: cs.foreground),
em: TextStyle(fontStyle: FontStyle.normal, color: cs.foreground),
// 链接:accentForeground + 下划线(浅色深绿提升对比,深色仍 lime)
a: TextStyle(
color: cs.accentForeground,
decoration: TextDecoration.underline,
decorationColor: cs.accentForeground,
),
// 表格:表头加粗、单元格留白、宽表横向滚动(IntrinsicColumnWidth 触发)、
// 外角小圆角(嵌在 11.2 圆角卡片内,用更小一档的 8)
tableHead: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: cs.foreground,
),
// 表格数据用等宽数字(规范 3.1:tabular-nums),多行数值纵向对齐
tableBody: TextStyle(
fontSize: 13,
height: 1.5,
color: cs.foreground,
fontFeatures: const [FontFeature.tabularFigures()],
),
tableHeadAlign: TextAlign.left,
tableColumnWidth: const IntrinsicColumnWidth(),
tableCellsPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
tablePadding: const EdgeInsets.only(bottom: 4),
tableBorder: TableBorder.all(
color: cs.border,
width: 1,
borderRadius: BorderRadius.circular(8),
),
// 引用块:中文不用斜体;浅底 + 主色左条 + 内距 + 右侧小圆角
blockquoteDecoration: BoxDecoration(
color: cs.muted,
border: Border(left: BorderSide(color: cs.primary, width: 3)),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(6),
bottomRight: Radius.circular(6),
),
),
blockquotePadding: const EdgeInsets.fromLTRB(14, 10, 12, 10),
blockquote: TextStyle(
fontSize: 13.5,
height: 1.7,
color: cs.mutedForeground,
),
// 行内 code(如 `机构名`):用正文字体(DM Sans),不用等宽 Menlo——
// 机构名/英文术语用等宽会显得突兀(demo 已改为正文字体)。仅保留浅底标记。
code: TextStyle(
fontSize: 13.5,
height: 1.75,
color: cs.foreground,
backgroundColor: cs.muted,
fontFamilyFallback: const ['PingFang SC', 'Noto Sans CJK SC'],
),
codeblockDecoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(8),
),
),
cs.border,
);
}
+299
View File
@@ -0,0 +1,299 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../util/external_link.dart';
/// GFM 表格的「填满 / 滚动」渲染。
///
/// flutter_markdown_plus 的内置 `_buildTable()` 二选一:FlexColumnWidth 填满但
/// 折行;IntrinsicColumnWidth 不折行但横向滚动里无法填满。自定义 builder 又被
/// 包强制覆盖。故把表格从 markdown 抽出,用本组件接管:
/// - 量出各列内容固有宽度;
/// - 总宽 ≤ 可用宽 → 按比例放大列宽**填满整行**(不滚动、不折行);
/// - 总宽 > 可用宽 → 用固有列宽 + 横向滚动查看(不折行)。
/// 单元格支持 `**加粗**` 与 `[文字](链接)`(点击走外部承接弹窗)。
class MarkdownTable extends StatelessWidget {
const MarkdownTable({
super.key,
required this.header,
required this.rows,
required this.aligns,
});
final List<String> header;
final List<List<String>> rows;
final List<TextAlign> aligns;
static const double _hPad = 12;
static const double _vPad = 8;
static const double _border = 1;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final n = header.length;
// 阅读字号倍率(表格在 ReaderScaled 子树内):量宽必须用同一 textScaler
// 否则缩放后列宽测算与实际渲染不符。
final textScaler = MediaQuery.textScalerOf(context);
final headStyle = TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: cs.foreground,
);
final bodyStyle = TextStyle(
fontSize: 13,
height: 1.5,
color: cs.foreground,
fontFeatures: const [FontFeature.tabularFigures()],
);
// 各列固有宽 = 该列最宽单元格(含表头) + 左右内距;按加粗量(偏宽)更稳妥
final intrinsic = List<double>.filled(n, 0);
void acc(List<String> cells) {
for (var c = 0; c < n && c < cells.length; c++) {
final w = _measure(
_stripMd(cells[c]),
bodyStyle.copyWith(fontWeight: FontWeight.w700),
textScaler,
);
if (w > intrinsic[c]) intrinsic[c] = w;
}
}
acc(header);
for (final r in rows) {
acc(r);
}
for (var c = 0; c < n; c++) {
intrinsic[c] += _hPad * 2;
}
return LayoutBuilder(
builder: (context, constraints) {
final avail = constraints.maxWidth;
final sum =
intrinsic.fold<double>(0, (a, b) => a + b) + (n + 1) * _border;
List<double> widths;
bool scroll;
if (sum <= avail) {
// 够宽:把多余宽度按列固有宽比例分摊,填满整行
final extra = avail - sum;
final total = intrinsic.fold<double>(0, (a, b) => a + b);
widths = [
for (final w in intrinsic)
w + (total > 0 ? extra * (w / total) : extra / n),
];
scroll = false;
} else {
widths = intrinsic;
scroll = true;
}
final table = Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
columnWidths: {
for (var c = 0; c < n; c++) c: FixedColumnWidth(widths[c]),
},
border: TableBorder.all(
color: cs.border,
width: _border,
borderRadius: BorderRadius.circular(8),
),
children: [
_row(context, header, headStyle, cs, isHeader: true),
for (final r in rows) _row(context, r, bodyStyle, cs),
],
);
if (!scroll) return table;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: avail),
child: table,
),
);
},
);
}
TableRow _row(
BuildContext context,
List<String> cells,
TextStyle style,
ShadColorScheme cs, {
bool isHeader = false,
}) {
return TableRow(
decoration: isHeader ? BoxDecoration(color: cs.muted) : null,
children: [
for (var c = 0; c < header.length; c++)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: _hPad,
vertical: _vPad,
),
child: Align(
alignment: _alignment(c),
child: Text.rich(
_inlineSpans(
context,
c < cells.length ? cells[c] : '',
style,
cs,
),
textAlign: c < aligns.length ? aligns[c] : TextAlign.left,
softWrap: false,
overflow: TextOverflow.visible,
),
),
),
],
);
}
Alignment _alignment(int c) {
final a = c < aligns.length ? aligns[c] : TextAlign.left;
return switch (a) {
TextAlign.center => Alignment.center,
TextAlign.right => Alignment.centerRight,
_ => Alignment.centerLeft,
};
}
static double _measure(String text, TextStyle style, TextScaler textScaler) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
textScaler: textScaler,
)..layout();
return tp.width;
}
}
// ── 解析:从 markdown 抽出表格块,其余按原文保留 ──────────────────────────
class MdSegment {
const MdSegment.text(this.text) : table = null;
const MdSegment.table(this.table) : text = '';
final String text;
final MarkdownTable? table;
bool get isTable => table != null;
}
final RegExp _sepLine = RegExp(r'^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$');
bool _isRowLine(String l) => l.contains('|');
bool _isSepLine(String l) =>
l.contains('-') && l.contains('|') && _sepLine.hasMatch(l);
List<String> _splitCells(String line) {
var t = line.trim();
if (t.startsWith('|')) t = t.substring(1);
if (t.endsWith('|')) t = t.substring(0, t.length - 1);
return t.split('|').map((s) => s.trim()).toList();
}
/// 把 markdown 拆成「文本段」与「表格段」,顺序保留。
List<MdSegment> splitMarkdownTables(String md) {
final lines = md.split('\n');
final out = <MdSegment>[];
final buf = <String>[];
void flush() {
if (buf.isNotEmpty) {
out.add(MdSegment.text(buf.join('\n')));
buf.clear();
}
}
var i = 0;
while (i < lines.length) {
final isHeader =
_isRowLine(lines[i]) &&
i + 1 < lines.length &&
_isSepLine(lines[i + 1]);
if (!isHeader) {
buf.add(lines[i]);
i++;
continue;
}
flush();
final headerCells = _splitCells(lines[i]);
// demo:忽略 markdown 列对齐,表格内容统一左对齐
// (右对齐会让右列大片留白只显几个字)。
final aligns = List<TextAlign>.filled(headerCells.length, TextAlign.left);
final rows = <List<String>>[];
var j = i + 2;
while (j < lines.length && _isRowLine(lines[j]) && !_isSepLine(lines[j])) {
final cs = _splitCells(lines[j]);
rows.add([
for (var c = 0; c < headerCells.length; c++) c < cs.length ? cs[c] : '',
]);
j++;
}
out.add(
MdSegment.table(
MarkdownTable(header: headerCells, rows: rows, aligns: aligns),
),
);
i = j;
}
flush();
return out;
}
// ── 单元格内联:**加粗** 与 [文字](链接) ────────────────────────────────
final RegExp _inlineToken = RegExp(r'\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)');
String _stripMd(String s) => s
.replaceAllMapped(_inlineToken, (m) => m.group(1) ?? m.group(2) ?? '')
.replaceAll('*', '');
InlineSpan _inlineSpans(
BuildContext context,
String raw,
TextStyle base,
ShadColorScheme cs,
) {
final spans = <InlineSpan>[];
var last = 0;
for (final m in _inlineToken.allMatches(raw)) {
if (m.start > last) {
spans.add(TextSpan(text: raw.substring(last, m.start), style: base));
}
if (m.group(1) != null) {
spans.add(
TextSpan(
text: m.group(1),
style: base.copyWith(fontWeight: FontWeight.w700),
),
);
} else {
final text = m.group(2)!;
final url = m.group(3)!;
spans.add(
TextSpan(
text: text,
style: base.copyWith(
color: cs.accentForeground,
decoration: TextDecoration.underline,
decorationColor: cs.accentForeground,
),
recognizer: TapGestureRecognizer()
..onTap = () =>
openExternalUrl(context, url, fallbackToast: '链接即将上线'),
),
);
}
last = m.end;
}
if (last < raw.length) {
spans.add(TextSpan(text: raw.substring(last), style: base));
}
return TextSpan(children: spans);
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_text.dart';
Future<bool?> showOutboundServiceSheet(
BuildContext context, {
String title = '即将打开外部服务',
String description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
String cancelLabel = '取消',
String confirmLabel = '继续前往',
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (_) => OutboundServiceSheet(
title: title,
description: description,
cancelLabel: cancelLabel,
confirmLabel: confirmLabel,
),
);
}
class OutboundServiceSheet extends StatelessWidget {
const OutboundServiceSheet({
super.key,
this.title = '即将打开外部服务',
this.description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
this.cancelLabel = '取消',
this.confirmLabel = '继续前往',
});
final String title;
final String description;
final String cancelLabel;
final String confirmLabel;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.background,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
border: Border.all(color: cs.border),
),
padding: EdgeInsets.fromLTRB(22, 12, 22, 22 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 70,
height: 5,
decoration: BoxDecoration(
color: cs.border,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 26),
Text(
title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 12),
Text(
description,
style: AppText.body.copyWith(
fontSize: 14,
height: 1.65,
color: cs.mutedForeground,
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ShadButton.outline(
foregroundColor: cs.foreground,
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelLabel),
),
),
const SizedBox(width: 12),
Expanded(
child: ShadButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/spacing.dart';
import 'adaptive.dart';
class PlaceholderTabPage extends StatelessWidget {
const PlaceholderTabPage({
super.key,
required this.title,
required this.description,
});
final String title;
final String description;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 24),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: cs.foreground,
letterSpacing: 0,
),
),
const SizedBox(height: 10),
Text(
description,
style: TextStyle(
fontSize: 14,
height: 1.6,
color: cs.mutedForeground,
letterSpacing: 0,
),
),
],
),
),
),
),
),
],
),
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/spacing.dart';
import '../theme/app_text.dart';
import 'adaptive.dart';
/// 各 tab 屏顶部标题区(对齐 demo `.apphead`)。
/// 大标题 34/800/1.15 + 可选副标题 15/muted。
class ScreenHeader extends StatelessWidget {
const ScreenHeader({super.key, required this.title, this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: Adaptive.screenPadding(context, top: 10, bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppText.appTitle.copyWith(color: cs.foreground)),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: AppText.body.copyWith(
color: cs.mutedForeground,
height: 1.4,
),
),
],
],
),
);
}
}
/// 分区标题(对齐 demo `.section-title`):22/700 + 可选右尖角。
class SectionTitle extends StatelessWidget {
const SectionTitle(this.title, {super.key, this.showChevron = false});
final String title;
final bool showChevron;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: Adaptive.screenPadding(
context,
top: Spacing.sectionGap,
bottom: 16,
),
child: Row(
children: [
Text(
title,
style: AppText.sectionTitle.copyWith(color: cs.foreground),
),
if (showChevron) ...[
const SizedBox(width: 6),
Icon(Icons.chevron_right, size: 18, color: cs.mutedForeground),
],
],
),
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:shimmer/shimmer.dart';
import '../theme/spacing.dart';
class SkeletonLineCard extends StatelessWidget {
const SkeletonLineCard({
super.key,
this.lineWidths = const [0.4, 1, 0.8, 0.6],
this.height,
});
final List<double> lineWidths;
final double? height;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
height: height,
width: double.infinity,
padding: const EdgeInsets.all(Spacing.cardPadding),
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border, width: 1),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < lineWidths.length; i++) ...[
_SkeletonLine(widthFactor: lineWidths[i]),
if (i != lineWidths.length - 1) const SizedBox(height: 10),
],
],
),
);
}
}
class SkeletonInstitutionCard extends StatelessWidget {
const SkeletonInstitutionCard({super.key});
@override
Widget build(BuildContext context) {
return const SkeletonLineCard(lineWidths: [0.4, 1, 0.6]);
}
}
class SkeletonListenCard extends StatelessWidget {
const SkeletonListenCard({super.key});
@override
Widget build(BuildContext context) {
return const SkeletonLineCard(lineWidths: [0.4, 1, 0.6]);
}
}
class SkeletonSectionCard extends StatelessWidget {
const SkeletonSectionCard({
super.key,
required this.roman,
required this.lineWidths,
this.baseColor,
this.highlightColor,
});
final String roman;
final List<double> lineWidths;
final Color? baseColor;
final Color? highlightColor;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final brightness = ShadTheme.of(context).brightness;
final resolvedBaseColor =
baseColor ??
(brightness == Brightness.dark
? cs.secondary
: cs.border.withValues(alpha: 0.95));
final resolvedHighlightColor =
highlightColor ??
(brightness == Brightness.dark
? cs.card
: cs.secondary.withValues(alpha: 0.95));
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Spacing.cardPadding,
vertical: 16,
),
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border, width: 1),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: Shimmer.fromColors(
baseColor: resolvedBaseColor,
highlightColor: resolvedHighlightColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < lineWidths.length; i++) ...[
_SkeletonLine(
widthFactor: lineWidths[i],
height: i == 0 ? 14 : 12,
),
if (i != lineWidths.length - 1) const SizedBox(height: 10),
],
const SizedBox(height: 2),
],
),
),
);
}
}
class _SkeletonLine extends StatelessWidget {
const _SkeletonLine({required this.widthFactor, this.height = 12});
final double widthFactor;
final double height;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return FractionallySizedBox(
widthFactor: widthFactor,
child: Shimmer.fromColors(
baseColor: cs.secondary,
highlightColor: cs.muted,
child: Container(
height: height,
decoration: BoxDecoration(
color: cs.secondary,
borderRadius: BorderRadius.circular(6),
),
),
),
);
}
}
class SectionSkeletonList extends StatelessWidget {
const SectionSkeletonList({
super.key,
required this.count,
required this.lineWidthsForIndex,
this.romanLabels,
});
final int count;
final List<double> Function(int index) lineWidthsForIndex;
final List<String>? romanLabels;
@override
Widget build(BuildContext context) {
final labels =
romanLabels ??
List<String>.generate(count, (index) => _roman(index + 1));
return SliverPadding(
padding: const EdgeInsets.fromLTRB(Spacing.page, 4, Spacing.page, 24),
sliver: SliverList.separated(
itemCount: count,
separatorBuilder: (_, _) => const SizedBox(height: Spacing.cardGap),
itemBuilder: (context, index) => SkeletonSectionCard(
roman: labels[index],
lineWidths: lineWidthsForIndex(index),
),
),
);
}
}
class SectionSkeletonListView extends StatelessWidget {
const SectionSkeletonListView({
super.key,
required this.count,
required this.lineWidthsForIndex,
this.romanLabels,
});
final int count;
final List<double> Function(int index) lineWidthsForIndex;
final List<String>? romanLabels;
@override
Widget build(BuildContext context) {
final labels =
romanLabels ??
List<String>.generate(count, (index) => _roman(index + 1));
return ListView.separated(
padding: const EdgeInsets.only(bottom: 72),
itemCount: count,
separatorBuilder: (_, _) => const SizedBox(height: Spacing.cardGap),
itemBuilder: (context, index) => SkeletonSectionCard(
roman: labels[index],
lineWidths: lineWidthsForIndex(index),
),
);
}
}
String _roman(int value) {
const numerals = ['I.', 'II.', 'III.', 'IV.', 'V.', 'VI.', 'VII.', 'VIII.'];
if (value <= 0 || value > numerals.length) return '$value.';
return numerals[value - 1];
}