feat:参照研听的基础工程
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../common/router/app_router.dart';
|
||||
import '../common/theme/jinzhi_theme.dart';
|
||||
import '../common/theme/spacing.dart';
|
||||
import '../common/theme/theme_controller.dart';
|
||||
|
||||
class JinzhiApp extends ConsumerWidget {
|
||||
const JinzhiApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final mode = ref.watch(themeModeControllerProvider);
|
||||
// DM Sans 作主字体(拉丁/数字),中文走系统级联(iOS 苹方 / Android 厂商
|
||||
// 中文字体)——主流做法,不显式点名、不内置字体。
|
||||
final dmSansFamily = GoogleFonts.dmSans().fontFamily;
|
||||
final textTheme = ShadTextTheme(family: dmSansFamily);
|
||||
return ShadApp.router(
|
||||
title: '金值',
|
||||
themeMode: mode,
|
||||
theme: ShadThemeData(
|
||||
brightness: Brightness.light,
|
||||
colorScheme: jinzhiLightScheme,
|
||||
textTheme: textTheme,
|
||||
radius: BorderRadius.circular(Spacing.radiusBase),
|
||||
),
|
||||
darkTheme: ShadThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: jinzhiDarkScheme,
|
||||
textTheme: textTheme,
|
||||
radius: BorderRadius.circular(Spacing.radiusBase),
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../common/services/device_header_service.dart';
|
||||
import '../common/storage/storage_service.dart';
|
||||
import 'app.dart';
|
||||
|
||||
Future<Widget> bootstrap() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await StorageService.instance;
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid) {
|
||||
// Before consent this only initializes app version and channel. The
|
||||
// service resumes device identity initialization after consent.
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
} else {
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
}
|
||||
}
|
||||
// if (!kIsWeb) {
|
||||
// await ShorebirdService.instance.initCurrentPatch();
|
||||
// ShorebirdService.instance.checkForUpdateInBackground();
|
||||
// }
|
||||
return const JinzhiApp();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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=xxx;Android 其次读 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-Id:iOS 优先 IDFV(per-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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// Hero(brand-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-tint(demo `.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),
|
||||
);
|
||||
@@ -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 容器)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 最宽 ~440,iPad 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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!),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
/// 左对齐到 12px(demo `.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
// 链接用深绿 accentForeground(lime 只做底色,不做文字色)
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../../market/data/mock_market_repository.dart';
|
||||
import '../data/asset_models.dart';
|
||||
import '../data/mock_asset_repository.dart';
|
||||
|
||||
final assetPortfolioControllerProvider =
|
||||
StateNotifierProvider<AssetPortfolioController, PortfolioSummary>((ref) {
|
||||
final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings();
|
||||
final market = ref.watch(mockMarketRepositoryProvider);
|
||||
return AssetPortfolioController(
|
||||
holdings: holdings,
|
||||
spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
|
||||
changeFor: (metal) => market.quoteFor(metal).changeAmount,
|
||||
);
|
||||
});
|
||||
|
||||
class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
|
||||
AssetPortfolioController({
|
||||
required List<GoldAssetHolding> holdings,
|
||||
required double Function(MetalType metal) spotPriceFor,
|
||||
required double Function(MetalType metal) changeFor,
|
||||
this.recycleDiscount = 0.97,
|
||||
}) : _holdings = holdings,
|
||||
_spotPriceFor = spotPriceFor,
|
||||
_changeFor = changeFor,
|
||||
super(
|
||||
_buildSummary(
|
||||
holdings: holdings,
|
||||
spotPriceFor: spotPriceFor,
|
||||
changeFor: changeFor,
|
||||
recycleDiscount: recycleDiscount,
|
||||
),
|
||||
);
|
||||
|
||||
final List<GoldAssetHolding> _holdings;
|
||||
final double Function(MetalType metal) _spotPriceFor;
|
||||
final double Function(MetalType metal) _changeFor;
|
||||
final double recycleDiscount;
|
||||
|
||||
void addHolding(GoldAssetHolding holding) {
|
||||
_holdings.add(holding);
|
||||
state = _buildSummary(
|
||||
holdings: _holdings,
|
||||
spotPriceFor: _spotPriceFor,
|
||||
changeFor: _changeFor,
|
||||
recycleDiscount: recycleDiscount,
|
||||
);
|
||||
}
|
||||
|
||||
static PortfolioSummary _buildSummary({
|
||||
required List<GoldAssetHolding> holdings,
|
||||
required double Function(MetalType metal) spotPriceFor,
|
||||
required double Function(MetalType metal) changeFor,
|
||||
required double recycleDiscount,
|
||||
}) {
|
||||
final active = holdings
|
||||
.where((holding) => holding.status == HoldingStatus.active)
|
||||
.toList(growable: false);
|
||||
final valuations = [
|
||||
for (final holding in active)
|
||||
HoldingValuation(
|
||||
holding: holding,
|
||||
materialValue: holding.materialValue(spotPriceFor(holding.metal)),
|
||||
todayChange:
|
||||
holding.weightGram * holding.purity * changeFor(holding.metal),
|
||||
),
|
||||
];
|
||||
|
||||
final totalValue = valuations.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + item.materialValue,
|
||||
);
|
||||
final todayChange = valuations.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + item.todayChange,
|
||||
);
|
||||
final totalCost = active.fold<double>(
|
||||
0,
|
||||
(sum, holding) => sum + (holding.costAmount ?? 0),
|
||||
);
|
||||
final totalWeight = active.fold<double>(
|
||||
0,
|
||||
(sum, holding) => sum + holding.weightGram,
|
||||
);
|
||||
|
||||
return PortfolioSummary(
|
||||
totalValue: totalValue,
|
||||
todayChangeAmount: todayChange,
|
||||
todayChangePercent: totalValue == 0 ? 0 : todayChange / totalValue * 100,
|
||||
totalCost: totalCost,
|
||||
totalWeightGram: totalWeight,
|
||||
recycleReferenceValue: totalValue * recycleDiscount,
|
||||
breakdowns: [
|
||||
for (final metal in MetalType.values)
|
||||
_breakdownFor(metal, active, spotPriceFor),
|
||||
].where((item) => item.count > 0).toList(growable: false),
|
||||
holdings: valuations,
|
||||
);
|
||||
}
|
||||
|
||||
static MetalBreakdown _breakdownFor(
|
||||
MetalType metal,
|
||||
List<GoldAssetHolding> holdings,
|
||||
double Function(MetalType metal) spotPriceFor,
|
||||
) {
|
||||
final items = holdings.where((holding) => holding.metal == metal).toList();
|
||||
return MetalBreakdown(
|
||||
metal: metal,
|
||||
value: items.fold<double>(
|
||||
0,
|
||||
(sum, holding) => sum + holding.materialValue(spotPriceFor(metal)),
|
||||
),
|
||||
weightGram: items.fold<double>(
|
||||
0,
|
||||
(sum, holding) => sum + holding.weightGram,
|
||||
),
|
||||
count: items.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
|
||||
enum AssetCategory {
|
||||
bar('金条'),
|
||||
necklace('项链'),
|
||||
ring('戒指'),
|
||||
bracelet('手镯'),
|
||||
bean('金豆'),
|
||||
earring('耳环'),
|
||||
silverware('银饰'),
|
||||
platinumPiece('铂金件');
|
||||
|
||||
const AssetCategory(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
||||
|
||||
enum HoldingStatus { active, sold, gifted }
|
||||
|
||||
class GoldAssetHolding {
|
||||
const GoldAssetHolding({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.metal,
|
||||
required this.category,
|
||||
required this.purity,
|
||||
required this.purityLabel,
|
||||
required this.weightGram,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.costAmount,
|
||||
this.purchaseDate,
|
||||
this.channel,
|
||||
this.note,
|
||||
this.status = HoldingStatus.active,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final MetalType metal;
|
||||
final AssetCategory category;
|
||||
final double purity;
|
||||
final String purityLabel;
|
||||
final double weightGram;
|
||||
final double? costAmount;
|
||||
final DateTime? purchaseDate;
|
||||
final String? channel;
|
||||
final String? note;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final HoldingStatus status;
|
||||
|
||||
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
|
||||
}
|
||||
|
||||
class HoldingValuation {
|
||||
const HoldingValuation({
|
||||
required this.holding,
|
||||
required this.materialValue,
|
||||
required this.todayChange,
|
||||
});
|
||||
|
||||
final GoldAssetHolding holding;
|
||||
final double materialValue;
|
||||
final double todayChange;
|
||||
}
|
||||
|
||||
class MetalBreakdown {
|
||||
const MetalBreakdown({
|
||||
required this.metal,
|
||||
required this.value,
|
||||
required this.weightGram,
|
||||
required this.count,
|
||||
});
|
||||
|
||||
final MetalType metal;
|
||||
final double value;
|
||||
final double weightGram;
|
||||
final int count;
|
||||
}
|
||||
|
||||
class PortfolioSummary {
|
||||
const PortfolioSummary({
|
||||
required this.totalValue,
|
||||
required this.todayChangeAmount,
|
||||
required this.todayChangePercent,
|
||||
required this.totalCost,
|
||||
required this.totalWeightGram,
|
||||
required this.recycleReferenceValue,
|
||||
required this.breakdowns,
|
||||
required this.holdings,
|
||||
});
|
||||
|
||||
final double totalValue;
|
||||
final double todayChangeAmount;
|
||||
final double todayChangePercent;
|
||||
final double totalCost;
|
||||
final double totalWeightGram;
|
||||
final double recycleReferenceValue;
|
||||
final List<MetalBreakdown> breakdowns;
|
||||
final List<HoldingValuation> holdings;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import 'asset_models.dart';
|
||||
|
||||
final mockAssetRepositoryProvider = Provider<MockAssetRepository>(
|
||||
(ref) => MockAssetRepository(),
|
||||
);
|
||||
|
||||
class MockAssetRepository {
|
||||
List<GoldAssetHolding> loadHoldings() {
|
||||
final now = DateTime(2026, 6, 18, 16, 11);
|
||||
return [
|
||||
GoldAssetHolding(
|
||||
id: 'holding_gold_bar_001',
|
||||
name: '投资金条',
|
||||
metal: MetalType.gold,
|
||||
category: AssetCategory.bar,
|
||||
purity: 0.9999,
|
||||
purityLabel: '足金9999',
|
||||
weightGram: 50,
|
||||
costAmount: 43200,
|
||||
purchaseDate: DateTime(2025, 10, 12),
|
||||
channel: '银行',
|
||||
createdAt: now.subtract(const Duration(days: 249)),
|
||||
updatedAt: now,
|
||||
),
|
||||
GoldAssetHolding(
|
||||
id: 'holding_gold_ring_001',
|
||||
name: '素圈戒指',
|
||||
metal: MetalType.gold,
|
||||
category: AssetCategory.ring,
|
||||
purity: 0.999,
|
||||
purityLabel: '足金999',
|
||||
weightGram: 8.6,
|
||||
costAmount: 7820,
|
||||
purchaseDate: DateTime(2024, 12, 4),
|
||||
channel: '金店',
|
||||
createdAt: now.subtract(const Duration(days: 196)),
|
||||
updatedAt: now,
|
||||
),
|
||||
GoldAssetHolding(
|
||||
id: 'holding_platinum_001',
|
||||
name: 'PT950 项链',
|
||||
metal: MetalType.platinum,
|
||||
category: AssetCategory.platinumPiece,
|
||||
purity: 0.95,
|
||||
purityLabel: 'PT950',
|
||||
weightGram: 12.3,
|
||||
costAmount: 5200,
|
||||
purchaseDate: DateTime(2023, 8, 21),
|
||||
channel: '金店',
|
||||
createdAt: now.subtract(const Duration(days: 667)),
|
||||
updatedAt: now,
|
||||
),
|
||||
GoldAssetHolding(
|
||||
id: 'holding_silver_001',
|
||||
name: '银手镯',
|
||||
metal: MetalType.silver,
|
||||
category: AssetCategory.bracelet,
|
||||
purity: 0.999,
|
||||
purityLabel: '999银',
|
||||
weightGram: 31.8,
|
||||
costAmount: 980,
|
||||
purchaseDate: DateTime(2024, 4, 7),
|
||||
channel: '金店',
|
||||
note: '材料价值偏低',
|
||||
createdAt: now.subtract(const Duration(days: 802)),
|
||||
updatedAt: now,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../common/domain/money.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../application/asset_portfolio_controller.dart';
|
||||
|
||||
class AssetsPage extends ConsumerWidget {
|
||||
const AssetsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref.watch(assetPortfolioControllerProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.contentMaxWidth,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('我的贵金属总估值', style: _mutedStyle(cs)),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
formatCny(summary.totalValue),
|
||||
style: TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: cs.foreground,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'今日 ${formatCny(summary.todayChangeAmount)} · ${summary.todayChangePercent.toStringAsFixed(2)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: summary.todayChangeAmount >= 0
|
||||
? const Color(0xFFC0392B)
|
||||
: const Color(0xFF2E8B6F),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final item in summary.breakdowns)
|
||||
Chip(
|
||||
label: Text(
|
||||
'${item.metal.shortLabel} ${formatCny(item.value)}',
|
||||
),
|
||||
avatar: CircleAvatar(
|
||||
backgroundColor: item.metal.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'我的持仓 · ${summary.holdings.length} 件 · ${formatGram(summary.totalWeightGram)}',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final item in summary.holdings)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${item.holding.name} · ${item.holding.purityLabel} · ${formatGram(item.holding.weightGram)}',
|
||||
style: TextStyle(color: cs.foreground),
|
||||
),
|
||||
),
|
||||
Text(formatCny(item.materialValue)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.card,
|
||||
border: Border.all(color: cs.border),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle _mutedStyle(ShadColorScheme cs) {
|
||||
return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/network/auth_interceptor.dart';
|
||||
import '../../../common/storage/storage_keys.dart';
|
||||
import '../../../common/storage/storage_service.dart';
|
||||
import '../data/auth_models.dart';
|
||||
|
||||
final authProvider =
|
||||
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
|
||||
(ref) => AuthController(),
|
||||
);
|
||||
|
||||
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
AuthController() : super(const AsyncLoading()) {
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
final token = await loadJwt();
|
||||
if (token == null || token.isEmpty) {
|
||||
state = const AsyncData(GuestAuthState());
|
||||
return;
|
||||
}
|
||||
final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: phone.isEmpty ? '13800000000' : phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime(2026, 6, 18),
|
||||
),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendCode(String phone) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Future<void> verifyCode(String phone, String code) async {
|
||||
final token = 'mock-jinzhi-token-$phone-$code';
|
||||
await saveJwt(token);
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, phone);
|
||||
state = AsyncData(
|
||||
LoggedInAuthState(
|
||||
user: JinzhiUser(
|
||||
phone: phone,
|
||||
nickname: '金值用户',
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await clearJwt();
|
||||
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
|
||||
state = const AsyncData(GuestAuthState());
|
||||
}
|
||||
|
||||
Future<void> deleteAccount([String? code]) async {
|
||||
await logout();
|
||||
}
|
||||
|
||||
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
class JinzhiUser {
|
||||
const JinzhiUser({
|
||||
required this.phone,
|
||||
required this.nickname,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String phone;
|
||||
final String nickname;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
|
||||
sealed class AuthState {
|
||||
const AuthState();
|
||||
}
|
||||
|
||||
class GuestAuthState extends AuthState {
|
||||
const GuestAuthState();
|
||||
}
|
||||
|
||||
class LoggedInAuthState extends AuthState {
|
||||
const LoggedInAuthState({required this.user, required this.token});
|
||||
|
||||
final JinzhiUser user;
|
||||
final String token;
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_remix/flutter_remix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../common/network/api_error_message.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/theme/app_text.dart';
|
||||
import '../../../common/theme/jinzhi_theme.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/widgets/circle_back_button.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
import '../data/auth_models.dart';
|
||||
|
||||
class DeleteAccountPage extends ConsumerWidget {
|
||||
const DeleteAccountPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authAsync = ref.watch(authProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
|
||||
return authAsync.when(
|
||||
loading: () => Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: Center(child: Text('加载失败:$e')),
|
||||
),
|
||||
data: (authState) {
|
||||
if (authState is! LoggedInAuthState) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
context.go(
|
||||
'${AppRoutes.login}?next=${Uri.encodeComponent(AppRoutes.deleteAccount)}',
|
||||
);
|
||||
});
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.background,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
'注销账号',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
leading: CircleBackButton(
|
||||
onTap: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(AppRoutes.settings);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.page,
|
||||
8,
|
||||
Spacing.page,
|
||||
28,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _WarnCard(),
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
'注销前请确认',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const _DeleteCheckList(),
|
||||
const SizedBox(height: 28),
|
||||
_DangerButton(
|
||||
label: '开始注销',
|
||||
onPressed: () =>
|
||||
_showVerifyDialog(context, ref, authState.user.phone),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'注销后,账号资料与历史内容将无法恢复。',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 11.5,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showVerifyDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String phone,
|
||||
) async {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final completed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
backgroundColor: cs.card,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.5),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(Spacing.radiusCard),
|
||||
),
|
||||
),
|
||||
builder: (_) => _VerifySheet(phone: phone),
|
||||
);
|
||||
if (!context.mounted || completed != true) return;
|
||||
context.go(AppRoutes.deleteAccountCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
String _maskPhone(String phone) {
|
||||
if (phone.length < 7) return phone;
|
||||
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
|
||||
}
|
||||
|
||||
/// 危险主按钮(demo `.da-btn`:destructive 实底 pill、52 高、16/600)。
|
||||
class _DangerButton extends StatelessWidget {
|
||||
const _DangerButton({required this.label, required this.onPressed});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onPressed,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.destructive,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.destructiveForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// sheet 内操作按钮(demo `.lo-btn`:52 高、radius 12、16/600;
|
||||
/// primary=lime / danger=destructive / cancel=secondary+描边)。
|
||||
enum _SheetBtnStyle { primary, danger, cancel }
|
||||
|
||||
class _SheetActionButton extends StatelessWidget {
|
||||
const _SheetActionButton({
|
||||
required this.label,
|
||||
required this.style,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final _SheetBtnStyle style;
|
||||
final VoidCallback onTap;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
Border? border;
|
||||
switch (style) {
|
||||
case _SheetBtnStyle.primary:
|
||||
bg = cs.primary;
|
||||
fg = cs.primaryForeground;
|
||||
case _SheetBtnStyle.danger:
|
||||
bg = cs.destructive;
|
||||
fg = cs.destructiveForeground;
|
||||
case _SheetBtnStyle.cancel:
|
||||
bg = cs.secondary;
|
||||
fg = cs.foreground;
|
||||
border = Border.all(color: cs.border);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Opacity(
|
||||
opacity: enabled ? 1 : 0.55,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: border,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinInput extends StatefulWidget {
|
||||
const _PinInput({
|
||||
required this.controller,
|
||||
required this.onCompleted,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onCompleted;
|
||||
final bool enabled;
|
||||
|
||||
static const length = 6;
|
||||
|
||||
@override
|
||||
State<_PinInput> createState() => _PinInputState();
|
||||
}
|
||||
|
||||
class _PinInputState extends State<_PinInput> {
|
||||
final _focusNode = FocusNode();
|
||||
String _lastText = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _PinInput oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.enabled && !oldWidget.enabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onChanged);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() {
|
||||
final value = widget.controller.text;
|
||||
if (value == _lastText) return;
|
||||
_lastText = value;
|
||||
setState(() {});
|
||||
if (value.length == _PinInput.length) {
|
||||
FocusScope.of(context).unfocus();
|
||||
widget.onCompleted(value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final filled = widget.controller.text;
|
||||
return GestureDetector(
|
||||
onTap: widget.enabled ? () => _focusNode.requestFocus() : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: List.generate(_PinInput.length, (i) {
|
||||
final hasChar = i < filled.length;
|
||||
final isFocus = i == filled.length && widget.enabled;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i == _PinInput.length - 1 ? 0 : 12,
|
||||
),
|
||||
child: _PinCell(
|
||||
char: hasChar ? filled[i] : '',
|
||||
focused: isFocus,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(_PinInput.length),
|
||||
],
|
||||
showCursor: false,
|
||||
autofocus: false,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinCell extends StatelessWidget {
|
||||
const _PinCell({required this.char, required this.focused, required this.cs});
|
||||
|
||||
final String char;
|
||||
final bool focused;
|
||||
final ShadColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
|
||||
border: Border.all(
|
||||
color: focused ? cs.primary : cs.border,
|
||||
width: focused ? 1.4 : 1,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
char,
|
||||
style: AppText.sectionTitle.copyWith(
|
||||
fontSize: 19,
|
||||
color: cs.foreground,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WarnCard extends StatelessWidget {
|
||||
const _WarnCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final b = ShadTheme.of(context).brightness;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(Spacing.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: destructiveSoft(b),
|
||||
border: Border.all(color: destructiveSoftBorder(b)),
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusCard),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.destructive,
|
||||
),
|
||||
child: Icon(
|
||||
FlutterRemix.alert_fill,
|
||||
size: 21,
|
||||
color: cs.destructiveForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
color: cs.foreground,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '此操作'),
|
||||
TextSpan(
|
||||
text: '不可恢复',
|
||||
style: TextStyle(color: cs.destructive),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'注销后,账号、阅读历史、个人数据将永久失效且无法找回。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.6,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteCheckList extends StatelessWidget {
|
||||
const _DeleteCheckList();
|
||||
|
||||
static const _items = [
|
||||
('账户处于安全状态', '您的账户处于安全状态,无异常登录。'),
|
||||
('账户信息将被清空', '您的个人账户相关信息将被清空且无法恢复。'),
|
||||
('浏览记录将被清空', '您最近阅读的文章将被清空且无法恢复。'),
|
||||
];
|
||||
|
||||
@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.radiusCard),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < _items.length; i++) ...[
|
||||
if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
|
||||
_DeleteCheckItem(
|
||||
number: i + 1,
|
||||
title: _items[i].$1,
|
||||
desc: _items[i].$2,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteCheckItem extends StatelessWidget {
|
||||
const _DeleteCheckItem({
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
});
|
||||
|
||||
final int number;
|
||||
final String title;
|
||||
final String desc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.accent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
'$number',
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.accentForeground,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.foreground,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
desc,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销短信验证底部 sheet(对齐 demo `deleteVerifySheet`):
|
||||
/// 未发送(说明 + 发送验证码[primary]/取消)→ 已发送(OTP 6 格 + 重发倒计时 +
|
||||
/// 确认注销[danger]/取消)→ 提交中(spinner)。
|
||||
class _VerifySheet extends ConsumerStatefulWidget {
|
||||
const _VerifySheet({required this.phone});
|
||||
|
||||
final String phone;
|
||||
|
||||
@override
|
||||
ConsumerState<_VerifySheet> createState() => _VerifySheetState();
|
||||
}
|
||||
|
||||
class _VerifySheetState extends ConsumerState<_VerifySheet> {
|
||||
final _otpController = TextEditingController();
|
||||
Timer? _timer;
|
||||
int _countdown = 0;
|
||||
bool _codeSent = false;
|
||||
bool _sending = false;
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_otpController.dispose();
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_timer?.cancel();
|
||||
setState(() => _countdown = 60);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) {
|
||||
t.cancel();
|
||||
return;
|
||||
}
|
||||
if (_countdown <= 1) {
|
||||
t.cancel();
|
||||
setState(() => _countdown = 0);
|
||||
} else {
|
||||
setState(() => _countdown -= 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
if (_sending || _countdown > 0) return;
|
||||
setState(() {
|
||||
_sending = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(authProvider.notifier).sendCode(widget.phone);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_codeSent = true;
|
||||
_otpController.clear();
|
||||
});
|
||||
_startCountdown();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final code = _otpController.text.trim().replaceAll(' ', '');
|
||||
if (code.length != 6) {
|
||||
setState(() => _error = '请输入6位验证码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await ref.read(authProvider.notifier).deleteAccount(code);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = kratosDisplayMessage(e, fallback: '注销失败,请重试');
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Padding(
|
||||
// 键盘弹出时 sheet 跟随上移
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// grip
|
||||
Center(
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.border,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'短信验证',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_submitting)
|
||||
..._buildSubmitting(cs)
|
||||
else if (!_codeSent)
|
||||
..._buildSendStep(cs)
|
||||
else
|
||||
..._buildCodeStep(cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildSubmitting(ShadColorScheme cs) {
|
||||
return [
|
||||
Text(
|
||||
'正在清除账户数据,请稍候',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildSendStep(ShadColorScheme cs) {
|
||||
return [
|
||||
Text(
|
||||
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
_SheetActionButton(
|
||||
label: _sending ? '发送中…' : '发送验证码',
|
||||
style: _SheetBtnStyle.primary,
|
||||
enabled: !_sending,
|
||||
onTap: _sendCode,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SheetActionButton(
|
||||
label: '取消',
|
||||
style: _SheetBtnStyle.cancel,
|
||||
enabled: !_sending,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildCodeStep(ShadColorScheme cs) {
|
||||
return [
|
||||
Text(
|
||||
'验证码已发送至 ${_maskPhone(widget.phone)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_PinInput(
|
||||
controller: _otpController,
|
||||
enabled: !_submitting,
|
||||
onCompleted: (_) => _confirm(),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _countdown > 0 || _sending ? null : _sendCode,
|
||||
child: Text(
|
||||
_countdown > 0 ? '$_countdown s 后可重新发送' : '重新发送',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: _countdown > 0 ? cs.mutedForeground : cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_SheetActionButton(
|
||||
label: '确认注销',
|
||||
style: _SheetBtnStyle.danger,
|
||||
enabled: !_submitting,
|
||||
onTap: _confirm,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SheetActionButton(
|
||||
label: '取消',
|
||||
style: _SheetBtnStyle.cancel,
|
||||
enabled: !_submitting,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteAccountSuccessPage extends ConsumerWidget {
|
||||
const DeleteAccountSuccessPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.page,
|
||||
72,
|
||||
Spacing.page,
|
||||
28,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: cs.primary,
|
||||
),
|
||||
child: Icon(
|
||||
FlutterRemix.check_line,
|
||||
size: 34,
|
||||
color: cs.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'账户已注销',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.4,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'您的账号及相关数据已清除完毕\n感谢您曾使用研听',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) context.go(AppRoutes.profile);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Text(
|
||||
'返回首页',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'如未来需再次使用,可重新注册账号\n但原历史数据无法迁移恢复',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppText.meta.copyWith(
|
||||
fontSize: 11,
|
||||
color: cs.mutedForeground,
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/network/api_error_message.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/theme/spacing.dart';
|
||||
import '../../../common/widgets/circle_back_button.dart';
|
||||
import '../application/auth_controller.dart';
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
const LoginPage({super.key, this.next});
|
||||
final String? next;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final phone = useTextEditingController();
|
||||
final codeCtrl = useTextEditingController();
|
||||
final codeFocus = useFocusNode();
|
||||
final onCodeStep = useState(false);
|
||||
final agreed = useState(false);
|
||||
final sending = useState(false);
|
||||
final verifying = useState(false);
|
||||
final countdown = useState(0);
|
||||
final error = useState<String?>(null);
|
||||
final timer = useRef<Timer?>(null);
|
||||
useListenable(phone);
|
||||
useListenable(codeCtrl);
|
||||
useEffect(
|
||||
() =>
|
||||
() => timer.value?.cancel(),
|
||||
const [],
|
||||
);
|
||||
|
||||
String phoneDigits() => phone.text.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
Future<void> returnAfterLogin() async {
|
||||
final target = next?.trim();
|
||||
final router = GoRouter.of(context);
|
||||
if (target == null || target.isEmpty) {
|
||||
router.go(AppRoutes.profile);
|
||||
return;
|
||||
}
|
||||
router.go(target);
|
||||
}
|
||||
|
||||
void startCountdown() {
|
||||
countdown.value = 60;
|
||||
timer.value?.cancel();
|
||||
timer.value = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (countdown.value <= 1) {
|
||||
t.cancel();
|
||||
countdown.value = 0;
|
||||
} else {
|
||||
countdown.value--;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> doSend() async {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (phoneDigits().length != 11) {
|
||||
error.value = '请输入正确的手机号';
|
||||
return;
|
||||
}
|
||||
sending.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await ref.read(authProvider.notifier).sendCode(phoneDigits());
|
||||
startCountdown();
|
||||
onCodeStep.value = true;
|
||||
codeFocus.requestFocus();
|
||||
} catch (e) {
|
||||
error.value = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> doVerify() async {
|
||||
if (verifying.value) return;
|
||||
final code = codeCtrl.text.trim();
|
||||
if (code.length != 6) {
|
||||
error.value = '请输入 6 位验证码';
|
||||
return;
|
||||
}
|
||||
verifying.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await ref.read(authProvider.notifier).verifyCode(phoneDigits(), code);
|
||||
if (!context.mounted) return;
|
||||
await returnAfterLogin();
|
||||
} catch (e) {
|
||||
error.value = kratosDisplayMessage(e, fallback: '验证失败,请重试');
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onBack() {
|
||||
if (onCodeStep.value) {
|
||||
onCodeStep.value = false;
|
||||
error.value = null;
|
||||
} else if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(AppRoutes.profile);
|
||||
}
|
||||
}
|
||||
|
||||
void showPrivacySheet(VoidCallback onAgreed) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: cs.card,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.6),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(Spacing.radiusCard),
|
||||
),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 28, 22, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'请阅读并同意以下条款',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
_policySpan(context, cs, fontSize: 12.5),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
_PrimaryButton(
|
||||
label: '同意并继续',
|
||||
enabled: true,
|
||||
onTap: () {
|
||||
Navigator.of(ctx).pop();
|
||||
onAgreed();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onSendTap() {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (phoneDigits().length != 11) {
|
||||
error.value = '请输入正确的手机号';
|
||||
return;
|
||||
}
|
||||
error.value = null;
|
||||
if (!agreed.value) {
|
||||
showPrivacySheet(() {
|
||||
agreed.value = true;
|
||||
doSend();
|
||||
});
|
||||
return;
|
||||
}
|
||||
doSend();
|
||||
}
|
||||
|
||||
final phoneReady = phoneDigits().length == 11;
|
||||
final codeReady = codeCtrl.text.trim().length == 6;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.background,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: 46,
|
||||
titleSpacing: 0,
|
||||
title: CircleBackButton(onTap: onBack),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(22, 8, 22, 28),
|
||||
child: onCodeStep.value
|
||||
? _CodeStep(
|
||||
cs: cs,
|
||||
phone: phoneDigits(),
|
||||
codeCtrl: codeCtrl,
|
||||
codeFocus: codeFocus,
|
||||
countdown: countdown.value,
|
||||
verifying: verifying.value,
|
||||
codeReady: codeReady,
|
||||
error: error.value,
|
||||
onVerify: doVerify,
|
||||
onResend: doSend,
|
||||
)
|
||||
: _PhoneStep(
|
||||
cs: cs,
|
||||
phone: phone,
|
||||
sending: sending.value,
|
||||
countdown: countdown.value,
|
||||
phoneReady: phoneReady,
|
||||
error: error.value,
|
||||
agreed: agreed.value,
|
||||
onToggleAgreed: () => agreed.value = !agreed.value,
|
||||
onSend: onSendTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneStep extends StatelessWidget {
|
||||
const _PhoneStep({
|
||||
required this.cs,
|
||||
required this.phone,
|
||||
required this.sending,
|
||||
required this.countdown,
|
||||
required this.phoneReady,
|
||||
required this.agreed,
|
||||
required this.onToggleAgreed,
|
||||
required this.error,
|
||||
required this.onSend,
|
||||
});
|
||||
|
||||
final ShadColorScheme cs;
|
||||
final TextEditingController phone;
|
||||
final bool sending;
|
||||
final int countdown;
|
||||
final bool phoneReady;
|
||||
final bool agreed;
|
||||
final VoidCallback onToggleAgreed;
|
||||
final String? error;
|
||||
final VoidCallback onSend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SheetTitle('手机号登录', cs),
|
||||
const SizedBox(height: 8),
|
||||
_SheetSub('未注册的手机号验证后将自动创建研听账号。', cs),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+86',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.foreground,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _BoxedField(controller: phone, hint: '请输入手机号', cs: cs),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_ErrorText(error!, cs),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
_PrimaryButton(
|
||||
label: countdown > 0 ? '$countdown s 后可重新发送' : '获取验证码',
|
||||
enabled: !sending && countdown == 0,
|
||||
loading: sending,
|
||||
dim: !phoneReady,
|
||||
onTap: onSend,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeStep extends StatelessWidget {
|
||||
const _CodeStep({
|
||||
required this.cs,
|
||||
required this.phone,
|
||||
required this.codeCtrl,
|
||||
required this.codeFocus,
|
||||
required this.countdown,
|
||||
required this.verifying,
|
||||
required this.codeReady,
|
||||
required this.error,
|
||||
required this.onVerify,
|
||||
required this.onResend,
|
||||
});
|
||||
|
||||
final ShadColorScheme cs;
|
||||
final String phone;
|
||||
final TextEditingController codeCtrl;
|
||||
final FocusNode codeFocus;
|
||||
final int countdown;
|
||||
final bool verifying;
|
||||
final bool codeReady;
|
||||
final String? error;
|
||||
final VoidCallback onVerify;
|
||||
final VoidCallback onResend;
|
||||
|
||||
String get _masked => phone.length == 11
|
||||
? '${phone.substring(0, 3)}****${phone.substring(7)}'
|
||||
: phone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SheetTitle('输入验证码', cs),
|
||||
const SizedBox(height: 8),
|
||||
_SheetSub('验证码已发送至 +86 $_masked', cs),
|
||||
const SizedBox(height: 20),
|
||||
_OtpInput(controller: codeCtrl, focusNode: codeFocus, cs: cs),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ErrorText(error!, cs),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
_PrimaryButton(
|
||||
label: '验证并登录',
|
||||
enabled: !verifying,
|
||||
loading: verifying,
|
||||
dim: !codeReady,
|
||||
onTap: onVerify,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: countdown > 0 ? null : onResend,
|
||||
child: Text(
|
||||
countdown > 0 ? '$countdown s 后可重新发送' : '重新发送',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: countdown > 0 ? cs.mutedForeground : cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetTitle extends StatelessWidget {
|
||||
const _SheetTitle(this.text, this.cs);
|
||||
final String text;
|
||||
final ShadColorScheme cs;
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SheetSub extends StatelessWidget {
|
||||
const _SheetSub(this.text, this.cs);
|
||||
final String text;
|
||||
final ShadColorScheme cs;
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
|
||||
);
|
||||
}
|
||||
|
||||
class _ErrorText extends StatelessWidget {
|
||||
const _ErrorText(this.text, this.cs);
|
||||
final String text;
|
||||
final ShadColorScheme cs;
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Text(text, style: TextStyle(fontSize: 13, color: cs.destructive));
|
||||
}
|
||||
|
||||
class _BoxedField extends StatelessWidget {
|
||||
const _BoxedField({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
required this.cs,
|
||||
});
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final ShadColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 50,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
|
||||
border: Border.all(color: cs.input, width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [_PhoneNumberFormatter()],
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: cs.foreground,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
cursorColor: cs.primary,
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(fontSize: 15, color: cs.mutedForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneNumberFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
var d = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
if (d.length > 11) d = d.substring(0, 11);
|
||||
final b = StringBuffer();
|
||||
for (var i = 0; i < d.length; i++) {
|
||||
if (i == 3 || i == 7) b.write(' ');
|
||||
b.write(d[i]);
|
||||
}
|
||||
final s = b.toString();
|
||||
return TextEditingValue(
|
||||
text: s,
|
||||
selection: TextSelection.collapsed(offset: s.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OtpInput extends StatelessWidget {
|
||||
const _OtpInput({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.cs,
|
||||
});
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final ShadColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final code = controller.text;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: focusNode.requestFocus,
|
||||
child: SizedBox(
|
||||
height: 54,
|
||||
child: Stack(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (var i = 0; i < 6; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _OtpCell(
|
||||
digit: i < code.length ? code[i] : '',
|
||||
filled: i < code.length,
|
||||
cs: cs,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Positioned.fill(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
showCursor: false,
|
||||
autofocus: true,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
style: const TextStyle(color: Colors.transparent),
|
||||
decoration: const InputDecoration(
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OtpCell extends StatelessWidget {
|
||||
const _OtpCell({required this.digit, required this.filled, required this.cs});
|
||||
final String digit;
|
||||
final bool filled;
|
||||
final ShadColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.background,
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
|
||||
border: Border.all(color: filled ? cs.foreground : cs.input, width: 1),
|
||||
),
|
||||
child: Text(
|
||||
digit,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.foreground,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgreeRow extends StatelessWidget {
|
||||
const _AgreeRow({
|
||||
required this.agreed,
|
||||
required this.onToggle,
|
||||
required this.cs,
|
||||
});
|
||||
final bool agreed;
|
||||
final VoidCallback onToggle;
|
||||
final ShadColorScheme cs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onToggle,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(Spacing.radiusBadge),
|
||||
border: Border.all(
|
||||
color: agreed ? cs.primary : cs.border,
|
||||
width: 1.2,
|
||||
),
|
||||
color: agreed ? cs.primary : Colors.transparent,
|
||||
),
|
||||
child: agreed
|
||||
? Icon(
|
||||
FlutterRemix.check_line,
|
||||
size: 13,
|
||||
color: cs.primaryForeground,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text.rich(_policySpan(context, cs))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextSpan _policySpan(
|
||||
BuildContext context,
|
||||
ShadColorScheme cs, {
|
||||
double fontSize = 11.5,
|
||||
}) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.privacyUrl,
|
||||
isDark,
|
||||
);
|
||||
final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.userProtocolUrl,
|
||||
isDark,
|
||||
);
|
||||
|
||||
TextSpan link(String text, String url) => TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
color: cs.foreground,
|
||||
fontSize: fontSize,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: cs.foreground,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(url),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
);
|
||||
|
||||
return TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
height: 1.6,
|
||||
color: cs.mutedForeground,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '已阅读并同意 '),
|
||||
link('《用户协议》', protocolUrl),
|
||||
const TextSpan(text: ' 与 '),
|
||||
link('《隐私政策》', privacyUrl),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _PrimaryButton extends StatelessWidget {
|
||||
const _PrimaryButton({
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.onTap,
|
||||
this.loading = false,
|
||||
this.dim = false,
|
||||
});
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final bool loading;
|
||||
final bool dim;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final active = enabled && !dim;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? cs.primary : cs.muted,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: loading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: active ? cs.primaryForeground : cs.mutedForeground,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: active ? cs.primaryForeground : cs.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../data/market_models.dart';
|
||||
import '../data/mock_market_repository.dart';
|
||||
import 'market_controller.dart';
|
||||
|
||||
final exchangeCalculatorControllerProvider =
|
||||
StateNotifierProvider<
|
||||
ExchangeCalculatorController,
|
||||
ExchangeCalculatorState
|
||||
>((ref) {
|
||||
final market = ref.watch(marketControllerProvider);
|
||||
final repository = ref.watch(mockMarketRepositoryProvider);
|
||||
return ExchangeCalculatorController(
|
||||
repository: repository,
|
||||
initialMetal: market.selectedMetal,
|
||||
);
|
||||
});
|
||||
|
||||
class ExchangeCalculatorState {
|
||||
const ExchangeCalculatorState({
|
||||
required this.metal,
|
||||
required this.channels,
|
||||
required this.selectedChannel,
|
||||
required this.gram,
|
||||
required this.amount,
|
||||
required this.swapped,
|
||||
});
|
||||
|
||||
final MetalType metal;
|
||||
final List<ExchangeChannel> channels;
|
||||
final ExchangeChannel selectedChannel;
|
||||
final double gram;
|
||||
final double amount;
|
||||
final bool swapped;
|
||||
|
||||
ExchangeCalculatorState copyWith({
|
||||
MetalType? metal,
|
||||
List<ExchangeChannel>? channels,
|
||||
ExchangeChannel? selectedChannel,
|
||||
double? gram,
|
||||
double? amount,
|
||||
bool? swapped,
|
||||
}) {
|
||||
return ExchangeCalculatorState(
|
||||
metal: metal ?? this.metal,
|
||||
channels: channels ?? this.channels,
|
||||
selectedChannel: selectedChannel ?? this.selectedChannel,
|
||||
gram: gram ?? this.gram,
|
||||
amount: amount ?? this.amount,
|
||||
swapped: swapped ?? this.swapped,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExchangeCalculatorController
|
||||
extends StateNotifier<ExchangeCalculatorState> {
|
||||
ExchangeCalculatorController({
|
||||
required MockMarketRepository repository,
|
||||
required MetalType initialMetal,
|
||||
}) : _repository = repository,
|
||||
super(_initialState(repository, initialMetal));
|
||||
|
||||
final MockMarketRepository _repository;
|
||||
|
||||
void selectMetal(MetalType metal) {
|
||||
final channels = _repository.channelsFor(metal);
|
||||
final selected = channels.first;
|
||||
state = state.copyWith(
|
||||
metal: metal,
|
||||
channels: channels,
|
||||
selectedChannel: selected,
|
||||
amount: state.gram * selected.pricePerGram,
|
||||
);
|
||||
}
|
||||
|
||||
void selectChannel(String channelId) {
|
||||
final selected = state.channels.firstWhere(
|
||||
(channel) => channel.id == channelId,
|
||||
orElse: () => state.channels.first,
|
||||
);
|
||||
state = state.copyWith(
|
||||
selectedChannel: selected,
|
||||
amount: state.gram * selected.pricePerGram,
|
||||
);
|
||||
}
|
||||
|
||||
void setGram(double gram) {
|
||||
state = state.copyWith(
|
||||
gram: gram,
|
||||
amount: gram * state.selectedChannel.pricePerGram,
|
||||
);
|
||||
}
|
||||
|
||||
void setAmount(double amount) {
|
||||
state = state.copyWith(
|
||||
amount: amount,
|
||||
gram: amount / state.selectedChannel.pricePerGram,
|
||||
);
|
||||
}
|
||||
|
||||
void toggleSwapped() {
|
||||
state = state.copyWith(swapped: !state.swapped);
|
||||
}
|
||||
|
||||
static ExchangeCalculatorState _initialState(
|
||||
MockMarketRepository repository,
|
||||
MetalType metal,
|
||||
) {
|
||||
final channels = repository.channelsFor(metal);
|
||||
final selected = channels.first;
|
||||
const gram = 50.0;
|
||||
return ExchangeCalculatorState(
|
||||
metal: metal,
|
||||
channels: channels,
|
||||
selectedChannel: selected,
|
||||
gram: gram,
|
||||
amount: gram * selected.pricePerGram,
|
||||
swapped: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../data/market_models.dart';
|
||||
import '../data/mock_market_repository.dart';
|
||||
|
||||
final marketControllerProvider =
|
||||
StateNotifierProvider<MarketController, MarketState>((ref) {
|
||||
final repository = ref.watch(mockMarketRepositoryProvider);
|
||||
return MarketController(repository);
|
||||
});
|
||||
|
||||
class MarketState {
|
||||
const MarketState({
|
||||
required this.selectedMetal,
|
||||
required this.period,
|
||||
required this.quote,
|
||||
required this.points,
|
||||
required this.quotes,
|
||||
});
|
||||
|
||||
final MetalType selectedMetal;
|
||||
final MarketPeriod period;
|
||||
final MetalQuote quote;
|
||||
final List<MarketPoint> points;
|
||||
final List<MetalQuote> quotes;
|
||||
|
||||
MarketState copyWith({
|
||||
MetalType? selectedMetal,
|
||||
MarketPeriod? period,
|
||||
MetalQuote? quote,
|
||||
List<MarketPoint>? points,
|
||||
List<MetalQuote>? quotes,
|
||||
}) {
|
||||
return MarketState(
|
||||
selectedMetal: selectedMetal ?? this.selectedMetal,
|
||||
period: period ?? this.period,
|
||||
quote: quote ?? this.quote,
|
||||
points: points ?? this.points,
|
||||
quotes: quotes ?? this.quotes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MarketController extends StateNotifier<MarketState> {
|
||||
MarketController(this._repository)
|
||||
: super(
|
||||
MarketState(
|
||||
selectedMetal: MetalType.gold,
|
||||
period: MarketPeriod.h24,
|
||||
quote: _repository.quoteFor(MetalType.gold),
|
||||
points: _repository.chartFor(MetalType.gold, MarketPeriod.h24),
|
||||
quotes: _repository.getQuotes(),
|
||||
),
|
||||
);
|
||||
|
||||
final MockMarketRepository _repository;
|
||||
|
||||
void selectMetal(MetalType metal) {
|
||||
state = state.copyWith(
|
||||
selectedMetal: metal,
|
||||
quote: _repository.quoteFor(metal),
|
||||
points: _repository.chartFor(metal, state.period),
|
||||
);
|
||||
}
|
||||
|
||||
void selectPeriod(MarketPeriod period) {
|
||||
state = state.copyWith(
|
||||
period: period,
|
||||
points: _repository.chartFor(state.selectedMetal, period),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
|
||||
enum MarketPeriod {
|
||||
h24('24h', '24时'),
|
||||
d5('5d', '5日'),
|
||||
m1('1m', '1月'),
|
||||
m3('3m', '3月'),
|
||||
y1('1y', '1年');
|
||||
|
||||
const MarketPeriod(this.id, this.label);
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
}
|
||||
|
||||
enum ExchangeChannelKind { spot, bar, retail, td, recycle }
|
||||
|
||||
class MetalQuote {
|
||||
const MetalQuote({
|
||||
required this.metal,
|
||||
required this.spotPrice,
|
||||
required this.changeAmount,
|
||||
required this.changePercent,
|
||||
required this.updatedAt,
|
||||
required this.sourceLabel,
|
||||
required this.basisLabel,
|
||||
});
|
||||
|
||||
final MetalType metal;
|
||||
final double spotPrice;
|
||||
final double changeAmount;
|
||||
final double changePercent;
|
||||
final DateTime updatedAt;
|
||||
final String sourceLabel;
|
||||
final String basisLabel;
|
||||
}
|
||||
|
||||
class MarketPoint {
|
||||
const MarketPoint({
|
||||
required this.time,
|
||||
required this.open,
|
||||
required this.high,
|
||||
required this.low,
|
||||
required this.close,
|
||||
});
|
||||
|
||||
final DateTime time;
|
||||
final double open;
|
||||
final double high;
|
||||
final double low;
|
||||
final double close;
|
||||
}
|
||||
|
||||
class ExchangeChannel {
|
||||
const ExchangeChannel({
|
||||
required this.id,
|
||||
required this.metal,
|
||||
required this.name,
|
||||
required this.kind,
|
||||
required this.pricePerGram,
|
||||
required this.source,
|
||||
required this.updatedAt,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final MetalType metal;
|
||||
final String name;
|
||||
final ExchangeChannelKind kind;
|
||||
final double pricePerGram;
|
||||
final String source;
|
||||
final DateTime updatedAt;
|
||||
final String hint;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import 'market_models.dart';
|
||||
|
||||
final mockMarketRepositoryProvider = Provider<MockMarketRepository>(
|
||||
(ref) => MockMarketRepository(),
|
||||
);
|
||||
|
||||
class MockMarketRepository {
|
||||
MockMarketRepository();
|
||||
|
||||
final DateTime _updatedAt = DateTime(2026, 6, 18, 16, 11);
|
||||
|
||||
List<MetalQuote> getQuotes() {
|
||||
return [
|
||||
MetalQuote(
|
||||
metal: MetalType.gold,
|
||||
spotPrice: 943.05,
|
||||
changeAmount: 8.12,
|
||||
changePercent: 0.87,
|
||||
updatedAt: _updatedAt,
|
||||
sourceLabel: '行情聚合',
|
||||
basisLabel: '上海金现货参考价',
|
||||
),
|
||||
MetalQuote(
|
||||
metal: MetalType.platinum,
|
||||
spotPrice: 384.20,
|
||||
changeAmount: -2.46,
|
||||
changePercent: -0.64,
|
||||
updatedAt: _updatedAt,
|
||||
sourceLabel: '行情聚合',
|
||||
basisLabel: '国际盘换算参考价',
|
||||
),
|
||||
MetalQuote(
|
||||
metal: MetalType.silver,
|
||||
spotPrice: 15.75,
|
||||
changeAmount: 0.18,
|
||||
changePercent: 1.16,
|
||||
updatedAt: _updatedAt,
|
||||
sourceLabel: '行情聚合',
|
||||
basisLabel: '上海银现货参考价',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
MetalQuote quoteFor(MetalType metal) =>
|
||||
getQuotes().firstWhere((quote) => quote.metal == metal);
|
||||
|
||||
List<MarketPoint> chartFor(MetalType metal, MarketPeriod period) {
|
||||
final quote = quoteFor(metal);
|
||||
final points = <MarketPoint>[];
|
||||
final step = switch (period) {
|
||||
MarketPeriod.h24 => const Duration(hours: 2),
|
||||
MarketPeriod.d5 => const Duration(days: 1),
|
||||
MarketPeriod.m1 => const Duration(days: 3),
|
||||
MarketPeriod.m3 => const Duration(days: 9),
|
||||
MarketPeriod.y1 => const Duration(days: 30),
|
||||
};
|
||||
for (var i = 11; i >= 0; i--) {
|
||||
final drift = (i - 5.5) * quote.changeAmount / 10;
|
||||
final close = quote.spotPrice - drift;
|
||||
points.add(
|
||||
MarketPoint(
|
||||
time: _updatedAt.subtract(step * i),
|
||||
open: close - quote.changeAmount / 20,
|
||||
high: close + quote.spotPrice * 0.003,
|
||||
low: close - quote.spotPrice * 0.003,
|
||||
close: close,
|
||||
),
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
List<ExchangeChannel> channelsFor(MetalType metal, {double recycle = 0.97}) {
|
||||
final quote = quoteFor(metal);
|
||||
return switch (metal) {
|
||||
MetalType.gold => [
|
||||
_channel(
|
||||
'gold_spot',
|
||||
metal,
|
||||
'上海金现货',
|
||||
ExchangeChannelKind.spot,
|
||||
943.05,
|
||||
'上海金现货',
|
||||
'材料价值口径 · 非金店零售 / 回收价',
|
||||
),
|
||||
_channel(
|
||||
'gold_bar',
|
||||
metal,
|
||||
'投资金条',
|
||||
ExchangeChannelKind.bar,
|
||||
955.00,
|
||||
'银行 / 品牌金条参考',
|
||||
'投资金条参考 · 通常含少量升水',
|
||||
),
|
||||
_channel(
|
||||
'gold_ctf',
|
||||
metal,
|
||||
'周大福金店',
|
||||
ExchangeChannelKind.retail,
|
||||
1238.00,
|
||||
'品牌零售挂牌参考',
|
||||
'含工费与品牌溢价 · 以门店为准',
|
||||
),
|
||||
_channel(
|
||||
'gold_lfx',
|
||||
metal,
|
||||
'老凤祥金店',
|
||||
ExchangeChannelKind.retail,
|
||||
1248.00,
|
||||
'品牌零售挂牌参考',
|
||||
'含工费与品牌溢价 · 以门店为准',
|
||||
),
|
||||
_channel(
|
||||
'gold_td',
|
||||
metal,
|
||||
'黄金 T+D',
|
||||
ExchangeChannelKind.td,
|
||||
919.50,
|
||||
'SGE 递延参考',
|
||||
'交易品种参考 · 非实物购买价',
|
||||
),
|
||||
_channel(
|
||||
'gold_recycle',
|
||||
metal,
|
||||
'回收参考',
|
||||
ExchangeChannelKind.recycle,
|
||||
quote.spotPrice * recycle,
|
||||
'材料价值 × 回收折扣',
|
||||
'实际以门店检测为准',
|
||||
),
|
||||
],
|
||||
MetalType.platinum => [
|
||||
_channel(
|
||||
'platinum_spot',
|
||||
metal,
|
||||
'铂金现货',
|
||||
ExchangeChannelKind.spot,
|
||||
384.20,
|
||||
'国际盘换算参考',
|
||||
'材料价值口径 · 非饰品零售价',
|
||||
),
|
||||
_channel(
|
||||
'platinum_retail',
|
||||
metal,
|
||||
'铂金饰品',
|
||||
ExchangeChannelKind.retail,
|
||||
498.00,
|
||||
'零售挂牌参考',
|
||||
'含工费与品牌溢价',
|
||||
),
|
||||
_channel(
|
||||
'platinum_recycle',
|
||||
metal,
|
||||
'回收参考',
|
||||
ExchangeChannelKind.recycle,
|
||||
quote.spotPrice * recycle,
|
||||
'材料价值 × 回收折扣',
|
||||
'实际以门店检测为准',
|
||||
),
|
||||
],
|
||||
MetalType.silver => [
|
||||
_channel(
|
||||
'silver_spot',
|
||||
metal,
|
||||
'白银现货',
|
||||
ExchangeChannelKind.spot,
|
||||
15.75,
|
||||
'上海银现货',
|
||||
'材料价值口径 · 非饰品零售价',
|
||||
),
|
||||
_channel(
|
||||
'silver_retail',
|
||||
metal,
|
||||
'白银饰品',
|
||||
ExchangeChannelKind.retail,
|
||||
42.00,
|
||||
'零售挂牌参考',
|
||||
'含工费与品牌溢价',
|
||||
),
|
||||
_channel(
|
||||
'silver_recycle',
|
||||
metal,
|
||||
'回收参考',
|
||||
ExchangeChannelKind.recycle,
|
||||
quote.spotPrice * recycle,
|
||||
'材料价值 × 回收折扣',
|
||||
'实际以门店检测为准',
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
ExchangeChannel _channel(
|
||||
String id,
|
||||
MetalType metal,
|
||||
String name,
|
||||
ExchangeChannelKind kind,
|
||||
double price,
|
||||
String source,
|
||||
String hint,
|
||||
) {
|
||||
return ExchangeChannel(
|
||||
id: id,
|
||||
metal: metal,
|
||||
name: name,
|
||||
kind: kind,
|
||||
pricePerGram: price,
|
||||
source: source,
|
||||
updatedAt: _updatedAt,
|
||||
hint: hint,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../../../common/domain/money.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../application/exchange_calculator_controller.dart';
|
||||
import '../application/market_controller.dart';
|
||||
import '../data/market_models.dart';
|
||||
|
||||
class MarketPage extends ConsumerWidget {
|
||||
const MarketPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final market = ref.watch(marketControllerProvider);
|
||||
final exchange = ref.watch(exchangeCalculatorControllerProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.contentMaxWidth,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SegmentedButton<MetalType>(
|
||||
segments: [
|
||||
for (final metal in MetalType.values)
|
||||
ButtonSegment(value: metal, label: Text(metal.label)),
|
||||
],
|
||||
selected: {market.selectedMetal},
|
||||
onSelectionChanged: (next) {
|
||||
final metal = next.first;
|
||||
ref
|
||||
.read(marketControllerProvider.notifier)
|
||||
.selectMetal(metal);
|
||||
ref
|
||||
.read(exchangeCalculatorControllerProvider.notifier)
|
||||
.selectMetal(metal);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(market.quote.basisLabel, style: _mutedStyle(cs)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${formatCny(market.quote.spotPrice)} / 克',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${market.quote.changeAmount >= 0 ? '+' : ''}${market.quote.changeAmount.toStringAsFixed(2)} · ${market.quote.changePercent.toStringAsFixed(2)}%',
|
||||
style: TextStyle(
|
||||
color: market.quote.changeAmount >= 0
|
||||
? const Color(0xFFC0392B)
|
||||
: const Color(0xFF2E8B6F),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final period in MarketPeriod.values)
|
||||
ChoiceChip(
|
||||
label: Text(period.label),
|
||||
selected: period == market.period,
|
||||
onSelected: (_) => ref
|
||||
.read(marketControllerProvider.notifier)
|
||||
.selectPeriod(period),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'走势图 mock 点位:${market.points.length} 个',
|
||||
style: _mutedStyle(cs),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'兑换试算',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${formatGram(exchange.gram)} ${exchange.metal.label} = ${formatCny(exchange.amount)}',
|
||||
style: TextStyle(fontSize: 20, color: cs.foreground),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'1 克 ${exchange.metal.label} = ${formatCny(exchange.selectedChannel.pricePerGram)} · ${exchange.selectedChannel.source}',
|
||||
style: _mutedStyle(cs),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final channel in exchange.channels)
|
||||
ChoiceChip(
|
||||
label: Text(channel.name),
|
||||
selected:
|
||||
channel.id == exchange.selectedChannel.id,
|
||||
onSelected: (_) => ref
|
||||
.read(
|
||||
exchangeCalculatorControllerProvider
|
||||
.notifier,
|
||||
)
|
||||
.selectChannel(channel.id),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
exchange.selectedChannel.hint,
|
||||
style: _mutedStyle(cs),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.card,
|
||||
border: Border.all(color: cs.border),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle _mutedStyle(ShadColorScheme cs) {
|
||||
return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../data/mock_news_repository.dart';
|
||||
import '../data/news_models.dart';
|
||||
|
||||
final newsControllerProvider = StateNotifierProvider<NewsController, NewsState>(
|
||||
(ref) => NewsController(ref.watch(mockNewsRepositoryProvider)),
|
||||
);
|
||||
|
||||
class NewsState {
|
||||
const NewsState({required this.items, required this.refreshedAt});
|
||||
|
||||
final List<NewsItem> items;
|
||||
final DateTime refreshedAt;
|
||||
}
|
||||
|
||||
class NewsController extends StateNotifier<NewsState> {
|
||||
NewsController(this._repository)
|
||||
: super(
|
||||
NewsState(
|
||||
items: _repository.loadFeed(),
|
||||
refreshedAt: DateTime(2026, 6, 18, 16, 11),
|
||||
),
|
||||
);
|
||||
|
||||
final MockNewsRepository _repository;
|
||||
|
||||
void refresh() {
|
||||
state = NewsState(
|
||||
items: _repository.loadFeed(),
|
||||
refreshedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import 'news_models.dart';
|
||||
|
||||
final mockNewsRepositoryProvider = Provider<MockNewsRepository>(
|
||||
(ref) => MockNewsRepository(),
|
||||
);
|
||||
|
||||
class MockNewsRepository {
|
||||
List<NewsItem> loadFeed() {
|
||||
final base = DateTime(2026, 6, 18, 16, 11);
|
||||
final items = [
|
||||
NewsItem(
|
||||
id: 'news_gold_001',
|
||||
metal: MetalType.gold,
|
||||
title: '金价维持高位震荡,实物金溢价继续分化',
|
||||
source: '金值整理',
|
||||
publishedAt: base.subtract(const Duration(minutes: 18)),
|
||||
summary: '现货价与品牌零售价之间仍有明显价差,持仓估值应优先看材料价值。',
|
||||
body: const [
|
||||
'今日黄金现货价格维持高位震荡,品牌金店挂牌价与现货材料价值之间仍有明显差距。',
|
||||
'对持有者而言,材料价值更适合用于日常估值;对购买者而言,需要额外关注工费、品牌溢价与回收折扣。',
|
||||
],
|
||||
),
|
||||
NewsItem(
|
||||
id: 'news_silver_001',
|
||||
metal: MetalType.silver,
|
||||
title: '白银跟随工业金属情绪回暖,短线波动放大',
|
||||
source: '市场简报',
|
||||
publishedAt: base.subtract(const Duration(hours: 1, minutes: 4)),
|
||||
summary: '银价弹性较强,饰品材料价值和零售购买价差异更大。',
|
||||
body: const [
|
||||
'白银价格今日跟随工业金属情绪回暖,短线波动较黄金更明显。',
|
||||
'银饰通常包含较高加工与零售成本,材料价值占比可能低于用户直觉。',
|
||||
],
|
||||
),
|
||||
NewsItem(
|
||||
id: 'news_platinum_001',
|
||||
metal: MetalType.platinum,
|
||||
title: '铂金回收报价偏谨慎,饰品估值需看纯度',
|
||||
source: '金属观察',
|
||||
publishedAt: base.subtract(const Duration(hours: 2, minutes: 36)),
|
||||
summary: 'PT950 饰品估值建议按克重、纯度和回收折扣分层查看。',
|
||||
body: const [
|
||||
'铂金饰品回收报价通常更依赖门店检测与成色确认,报价口径比现货价格更谨慎。',
|
||||
'记录持仓时建议保留纯度、克重、购买渠道和成本信息,便于后续估值与盈亏回看。',
|
||||
],
|
||||
),
|
||||
];
|
||||
return [...items]..sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
|
||||
class NewsItem {
|
||||
const NewsItem({
|
||||
required this.id,
|
||||
required this.metal,
|
||||
required this.title,
|
||||
required this.source,
|
||||
required this.publishedAt,
|
||||
required this.summary,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final MetalType metal;
|
||||
final String title;
|
||||
final String source;
|
||||
final DateTime publishedAt;
|
||||
final String summary;
|
||||
final List<String> body;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../application/news_controller.dart';
|
||||
|
||||
class NewsPage extends ConsumerWidget {
|
||||
const NewsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final news = ref.watch(newsControllerProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.contentMaxWidth,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'信息整理 · 非投资建议',
|
||||
style: TextStyle(
|
||||
color: cs.mutedForeground,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(newsControllerProvider.notifier).refresh(),
|
||||
child: const Text('刷新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final item in news.items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.card,
|
||||
border: Border.all(color: cs.border),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.metal.color.withValues(
|
||||
alpha: 0.12,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
item.metal.shortLabel,
|
||||
style: TextStyle(
|
||||
color: item.metal.color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${item.source} · ${item.publishedAt.hour.toString().padLeft(2, '0')}:${item.publishedAt.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
color: cs.mutedForeground,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.summary,
|
||||
style: TextStyle(
|
||||
color: cs.mutedForeground,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
import '../data/profile_models.dart';
|
||||
|
||||
final profileSettingsControllerProvider =
|
||||
StateNotifierProvider<ProfileSettingsController, ProfileSettings>(
|
||||
(ref) => ProfileSettingsController(),
|
||||
);
|
||||
|
||||
class ProfileSettingsController extends StateNotifier<ProfileSettings> {
|
||||
ProfileSettingsController()
|
||||
: super(
|
||||
const ProfileSettings(
|
||||
amountHidden: false,
|
||||
recycleDiscounts: {
|
||||
MetalType.gold: 0.97,
|
||||
MetalType.platinum: 0.93,
|
||||
MetalType.silver: 0.88,
|
||||
},
|
||||
purityPresets: {
|
||||
'足金9999': 0.9999,
|
||||
'足金999': 0.999,
|
||||
'PT950': 0.95,
|
||||
'999银': 0.999,
|
||||
},
|
||||
priceColorMode: PriceColorMode.redUpGreenDown,
|
||||
mockLoggedIn: false,
|
||||
),
|
||||
);
|
||||
|
||||
void toggleAmountHidden() {
|
||||
state = state.copyWith(amountHidden: !state.amountHidden);
|
||||
}
|
||||
|
||||
void setMockLoggedIn(bool value) {
|
||||
state = state.copyWith(mockLoggedIn: value);
|
||||
}
|
||||
|
||||
void setPriceColorMode(PriceColorMode mode) {
|
||||
state = state.copyWith(priceColorMode: mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import '../../../common/domain/metal_type.dart';
|
||||
|
||||
enum PriceColorMode {
|
||||
redUpGreenDown('红涨绿跌'),
|
||||
greenUpRedDown('绿涨红跌');
|
||||
|
||||
const PriceColorMode(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
||||
|
||||
class ProfileSettings {
|
||||
const ProfileSettings({
|
||||
required this.amountHidden,
|
||||
required this.recycleDiscounts,
|
||||
required this.purityPresets,
|
||||
required this.priceColorMode,
|
||||
required this.mockLoggedIn,
|
||||
});
|
||||
|
||||
final bool amountHidden;
|
||||
final Map<MetalType, double> recycleDiscounts;
|
||||
final Map<String, double> purityPresets;
|
||||
final PriceColorMode priceColorMode;
|
||||
final bool mockLoggedIn;
|
||||
|
||||
ProfileSettings copyWith({
|
||||
bool? amountHidden,
|
||||
Map<MetalType, double>? recycleDiscounts,
|
||||
Map<String, double>? purityPresets,
|
||||
PriceColorMode? priceColorMode,
|
||||
bool? mockLoggedIn,
|
||||
}) {
|
||||
return ProfileSettings(
|
||||
amountHidden: amountHidden ?? this.amountHidden,
|
||||
recycleDiscounts: recycleDiscounts ?? this.recycleDiscounts,
|
||||
purityPresets: purityPresets ?? this.purityPresets,
|
||||
priceColorMode: priceColorMode ?? this.priceColorMode,
|
||||
mockLoggedIn: mockLoggedIn ?? this.mockLoggedIn,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../../common/router/app_router.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../profile/application/profile_settings_controller.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(profileSettingsControllerProvider);
|
||||
final auth = ref.watch(authProvider);
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
final loggedIn = auth.valueOrNull is LoggedInAuthState;
|
||||
final currentUser = auth.valueOrNull is LoggedInAuthState
|
||||
? (auth.valueOrNull as LoggedInAuthState).user
|
||||
: null;
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: Adaptive.contentMaxWidth,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Card(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentUser?.nickname ?? '未登录',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
currentUser?.phone ?? '登录后可使用本地同步、云备份占位和账户管理。',
|
||||
style: TextStyle(color: cs.mutedForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => context.push(AppRoutes.settings),
|
||||
child: const Text('设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
children: [
|
||||
_SettingRow(
|
||||
label: '隐藏金额',
|
||||
value: settings.amountHidden ? '已隐藏' : '显示中',
|
||||
trailing: Switch(
|
||||
value: settings.amountHidden,
|
||||
onChanged: (_) => ref
|
||||
.read(
|
||||
profileSettingsControllerProvider.notifier,
|
||||
)
|
||||
.toggleAmountHidden(),
|
||||
),
|
||||
),
|
||||
_SettingRow(
|
||||
label: '回收折扣',
|
||||
value:
|
||||
'金 ${(settings.recycleDiscounts.values.first * 100).toStringAsFixed(0)}%',
|
||||
),
|
||||
_SettingRow(
|
||||
label: '涨跌颜色',
|
||||
value: settings.priceColorMode.label,
|
||||
),
|
||||
_SettingRow(
|
||||
label: '纯度系数',
|
||||
value: '${settings.purityPresets.length} 项',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
onPressed: loggedIn
|
||||
? () => context.push(AppRoutes.login)
|
||||
: () => context.push(AppRoutes.login),
|
||||
child: Text(loggedIn ? '切换账号' : '去登录'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.push(AppRoutes.settings),
|
||||
child: const Text('打开设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Card(
|
||||
child: Text(
|
||||
'风险提示 · 数据来源 · 免责\n价格、资讯与估值均为本地 mock 数据,仅用于产品原型验证,不构成投资建议。',
|
||||
style: TextStyle(
|
||||
color: cs.mutedForeground,
|
||||
height: 1.6,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingRow extends StatelessWidget {
|
||||
const _SettingRow({required this.label, required this.value, this.trailing});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||
),
|
||||
),
|
||||
Text(value, style: TextStyle(color: cs.mutedForeground)),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = ShadTheme.of(context).colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.card,
|
||||
border: Border.all(color: cs.border),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../common/config/app_h5_urls.dart';
|
||||
import '../../../common/services/device_header_service.dart';
|
||||
import '../../../common/theme/jinzhi_theme.dart';
|
||||
import '../../../common/widgets/adaptive.dart';
|
||||
import '../../../common/router/app_router.dart';
|
||||
|
||||
class PrivacyConsentPage extends StatefulWidget {
|
||||
const PrivacyConsentPage({super.key});
|
||||
|
||||
@override
|
||||
State<PrivacyConsentPage> createState() => _PrivacyConsentPageState();
|
||||
}
|
||||
|
||||
class _PrivacyConsentPageState extends State<PrivacyConsentPage> {
|
||||
bool _dialogShown = false;
|
||||
late final VideoPlayerController _video;
|
||||
bool _videoReady = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_video = VideoPlayerController.asset('assets/launch/launch-bg.mp4')
|
||||
..setLooping(true)
|
||||
..setVolume(0)
|
||||
..initialize().then((_) {
|
||||
if (!mounted) return;
|
||||
_video.setPlaybackSpeed(0.5);
|
||||
_video.play();
|
||||
setState(() => _videoReady = true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_video.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _agree() async {
|
||||
await DeviceHeaderService.to.markPrivacyAgreed();
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
if (mounted) context.go(AppRoutes.assets);
|
||||
}
|
||||
|
||||
Future<void> _enter() async {
|
||||
await DeviceHeaderService.to.markIosStartupSeen();
|
||||
if (mounted) context.go(AppRoutes.assets);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const cs = jinzhiDarkScheme;
|
||||
final showAndroidConsent = DeviceHeaderService.to.privacyConsentRequired;
|
||||
|
||||
if (showAndroidConsent && !_dialogShown) {
|
||||
_dialogShown = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.55),
|
||||
builder: (_) => _PrivacyConsentSheet(onAgree: _agree),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_videoReady)
|
||||
FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: _video.value.size.width,
|
||||
height: _video.value.size.height,
|
||||
child: VideoPlayer(_video),
|
||||
),
|
||||
)
|
||||
else
|
||||
Image.asset('assets/launch/launch-poster.jpg', fit: BoxFit.cover),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0.0, 0.46, 0.88],
|
||||
colors: [
|
||||
cs.background.withValues(alpha: 0.66),
|
||||
cs.background.withValues(alpha: 0.82),
|
||||
cs.background,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const _LaunchMark(size: 88),
|
||||
const SizedBox(height: 26),
|
||||
Text(
|
||||
'研听',
|
||||
style: TextStyle(
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: cs.foreground,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'读懂全球研报',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: cs.foreground.withValues(alpha: 0.82),
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!showAndroidConsent)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 0, 22, 20),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: Adaptive.isTablet(context)
|
||||
? 400
|
||||
: double.infinity,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: _enter,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Text(
|
||||
'立即进入',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Text(
|
||||
'内容为公开研报的结构化解读,不构成投资建议',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: cs.mutedForeground.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LaunchMark extends StatefulWidget {
|
||||
const _LaunchMark({this.size = 88});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
State<_LaunchMark> createState() => _LaunchMarkState();
|
||||
}
|
||||
|
||||
class _LaunchMarkState extends State<_LaunchMark>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _spin = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 8),
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_spin.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = widget.size;
|
||||
const cxf = 0.6758, cyf = 0.3242, sideF = 0.4121;
|
||||
final side = m * sideF;
|
||||
return SizedBox(
|
||||
width: m,
|
||||
height: m,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset('assets/launch/mark-grid-lime.png'),
|
||||
),
|
||||
Positioned(
|
||||
left: cxf * m - side / 2,
|
||||
top: cyf * m - side / 2,
|
||||
width: side,
|
||||
height: side,
|
||||
child: RotationTransition(
|
||||
turns: _spin,
|
||||
child: Image.asset('assets/launch/mark-star-lime.png'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivacyConsentSheet extends StatefulWidget {
|
||||
const _PrivacyConsentSheet({required this.onAgree});
|
||||
|
||||
final Future<void> Function() onAgree;
|
||||
|
||||
@override
|
||||
State<_PrivacyConsentSheet> createState() => _PrivacyConsentSheetState();
|
||||
}
|
||||
|
||||
class _PrivacyConsentSheetState extends State<_PrivacyConsentSheet> {
|
||||
bool _verify = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const cs = jinzhiDarkScheme;
|
||||
final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.privacyUrl,
|
||||
false,
|
||||
);
|
||||
final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
|
||||
AppH5Urls.userProtocolUrl,
|
||||
false,
|
||||
);
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.card,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(11.2)),
|
||||
border: Border.all(color: cs.border),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: _verify
|
||||
? _verifyView(cs, protocolUrl, privacyUrl)
|
||||
: _consentView(cs, protocolUrl, privacyUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _grip(ShadColorScheme cs) => Center(
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.border,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _consentView(
|
||||
ShadColorScheme cs,
|
||||
String protocolUrl,
|
||||
String privacyUrl,
|
||||
) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_grip(cs),
|
||||
Text(
|
||||
'欢迎使用研听',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_policyBody(cs, protocolUrl: protocolUrl, privacyUrl: privacyUrl),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Btn(
|
||||
label: '不同意',
|
||||
primary: false,
|
||||
cs: cs,
|
||||
onTap: () => setState(() => _verify = true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Btn(
|
||||
label: '同意',
|
||||
primary: true,
|
||||
cs: cs,
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
await widget.onAgree();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _verifyView(
|
||||
ShadColorScheme cs,
|
||||
String protocolUrl,
|
||||
String privacyUrl,
|
||||
) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_grip(cs),
|
||||
Text(
|
||||
'确认放弃使用?',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.foreground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_policyBody(cs, protocolUrl: protocolUrl, privacyUrl: privacyUrl),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Btn(
|
||||
label: '不同意并退出',
|
||||
primary: false,
|
||||
cs: cs,
|
||||
onTap: () => SystemNavigator.pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Btn(
|
||||
label: '同意',
|
||||
primary: true,
|
||||
cs: cs,
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
await widget.onAgree();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _policyBody(
|
||||
ShadColorScheme cs, {
|
||||
required String protocolUrl,
|
||||
required String privacyUrl,
|
||||
}) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(fontSize: 13, height: 1.7, color: cs.mutedForeground),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text:
|
||||
'尊敬的用户:\n\n我们非常重视您的个人信息和隐私保护,为了更好的保障您的个人权益,请您在使用我们的产品前,仔细阅读并充分理解 ',
|
||||
),
|
||||
_link('《用户协议》', protocolUrl, cs),
|
||||
const TextSpan(text: ' 和 '),
|
||||
_link('《隐私政策》', privacyUrl, cs),
|
||||
const TextSpan(
|
||||
text:
|
||||
'内容。我们将按照该协议内容收集、使用和共享您的个人信息。\n为了保证业务安全风控,在您使用我们基本功能的过程中,我们会收集您的手机号码,以及硬件序列号或唯一设备识别码(如 AndroidID/MAC/OAID/IMEI/WIFI 的 BSSID)等信息。\n如您同意,请点击“同意”开始接受我们的服务。',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _link(String text, String url, ShadColorScheme cs) => TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
color: cs.accentForeground,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: cs.accentForeground,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrlString(
|
||||
AppH5UrlsHelper.withCacheBuster(url),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Btn extends StatelessWidget {
|
||||
const _Btn({
|
||||
required this.label,
|
||||
required this.primary,
|
||||
required this.cs,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool primary;
|
||||
final ShadColorScheme cs;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'app/bootstrap.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
final app = await bootstrap();
|
||||
runApp(ProviderScope(child: app));
|
||||
}
|
||||
Reference in New Issue
Block a user