From f42ad566e0008f74cfc511666b88af9c83f43120 Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Thu, 18 Jun 2026 16:53:08 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E9=9D=A0=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=E4=B8=8D=E9=BD=90=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner.xcodeproj/project.pbxproj | 4 - lib/app/app.dart | 6 +- lib/common/router/app_router.dart | 118 ++ lib/common/router/tab_shell.dart | 196 ++- lib/common/widgets/prototype_ui.dart | 292 ++++ .../assets/presentation/assets_page.dart | 981 ++++++++++++-- .../presentation/holding_detail_page.dart | 262 ++++ .../presentation/market_kline_page.dart | 194 +++ .../market/presentation/market_page.dart | 494 +++++-- .../news/presentation/news_detail_page.dart | 112 ++ lib/features/news/presentation/news_page.dart | 183 ++- .../profile_settings_controller.dart | 9 + .../presentation/profile_action_pages.dart | 1203 +++++++++++++++++ .../profile/presentation/profile_page.dart | 528 ++++++-- 14 files changed, 4013 insertions(+), 569 deletions(-) create mode 100644 lib/common/widgets/prototype_ui.dart create mode 100644 lib/features/assets/presentation/holding_detail_page.dart create mode 100644 lib/features/market/presentation/market_kline_page.dart create mode 100644 lib/features/news/presentation/news_detail_page.dart create mode 100644 lib/features/profile/presentation/profile_action_pages.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 99b7492..1e95057 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -299,14 +299,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/lib/app/app.dart b/lib/app/app.dart index b0b3cdb..8918f71 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,5 +1,4 @@ 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'; @@ -15,10 +14,7 @@ class JinzhiApp extends ConsumerWidget { 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); + final textTheme = ShadTextTheme(); return ShadApp.router( title: '金值', themeMode: mode, diff --git a/lib/common/router/app_router.dart b/lib/common/router/app_router.dart index d9f927a..ab67510 100644 --- a/lib/common/router/app_router.dart +++ b/lib/common/router/app_router.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../features/assets/presentation/assets_page.dart'; +import '../../features/assets/presentation/holding_detail_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'; @@ -11,8 +12,11 @@ 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/market/presentation/market_kline_page.dart'; import '../../features/news/presentation/news_page.dart'; +import '../../features/news/presentation/news_detail_page.dart'; import '../../features/profile/presentation/profile_page.dart'; +import '../../features/profile/presentation/profile_action_pages.dart'; import '../../features/settings/presentation/settings_page.dart'; import '../../features/settings/presentation/privacy_consent_page.dart'; import '../services/device_header_service.dart'; @@ -21,8 +25,22 @@ import 'tab_shell.dart'; abstract final class AppRoutes { static const assets = '/assets'; static const market = '/market'; + static const marketKline = '/market/kline'; static const news = '/news'; + static const newsDetail = '/news/detail'; static const profile = '/profile'; + static const profileInvite = '/profile/invite'; + static const profileShare = '/profile/share'; + static const profileLocalBackup = '/profile/local-backup'; + static const profileCloudBackup = '/profile/cloud-backup'; + static const profileWidget = '/profile/widget'; + static const profilePurity = '/profile/purity'; + static const profileRecycle = '/profile/recycle'; + static const profilePriceColor = '/profile/price-color'; + static const profileCurrency = '/profile/currency'; + static const profileDisclaimer = '/profile/disclaimer'; + static const profileAbout = '/profile/about'; + static const assetDetail = '/assets/detail'; static const login = '/login'; static const settings = '/settings'; static const deleteAccount = '/delete-account'; @@ -73,6 +91,77 @@ final routerProvider = Provider((ref) { ), ], ), + GoRoute( + path: AppRoutes.assetDetail, + builder: (ctx, s) => + HoldingDetailPage(id: s.uri.queryParameters['id']), + ), + GoRoute( + path: AppRoutes.marketKline, + builder: (ctx, s) => MarketKlinePage( + metalId: s.uri.queryParameters['metal'], + periodId: s.uri.queryParameters['period'], + ), + ), + GoRoute( + path: AppRoutes.newsDetail, + builder: (ctx, s) => NewsDetailPage(id: s.uri.queryParameters['id']), + ), + GoRoute( + path: AppRoutes.profileInvite, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const InviteRewardPage()), + ), + GoRoute( + path: AppRoutes.profileShare, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const ShareCardPage()), + ), + GoRoute( + path: AppRoutes.profileLocalBackup, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const LocalBackupPage()), + ), + GoRoute( + path: AppRoutes.profileCloudBackup, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const CloudBackupPage()), + ), + GoRoute( + path: AppRoutes.profileWidget, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const WidgetSettingsPage()), + ), + GoRoute( + path: AppRoutes.profilePurity, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const PuritySettingsPage()), + ), + GoRoute( + path: AppRoutes.profileRecycle, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const RecycleDiscountPage()), + ), + GoRoute( + path: AppRoutes.profilePriceColor, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const PriceColorPage()), + ), + GoRoute( + path: AppRoutes.profileCurrency, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const CurrencyPage()), + ), + GoRoute( + path: AppRoutes.profileDisclaimer, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const DisclaimerPage()), + ), + GoRoute( + path: AppRoutes.profileAbout, + pageBuilder: (ctx, s) => + _bottomUpPage(state: s, child: const AboutPage()), + ), GoRoute(path: AppRoutes.debug, builder: (_, s) => const DebugPage()), GoRoute( path: AppRoutes.login, @@ -113,3 +202,32 @@ final routerProvider = Provider((ref) { ], ); }); + +CustomTransitionPage _bottomUpPage({ + required GoRouterState state, + required Widget child, +}) { + return CustomTransitionPage( + key: state.pageKey, + transitionDuration: const Duration(milliseconds: 260), + reverseTransitionDuration: const Duration(milliseconds: 220), + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, pageChild) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(curved), + child: FadeTransition( + opacity: curved, + child: pageChild, + ), + ); + }, + ); +} diff --git a/lib/common/router/tab_shell.dart b/lib/common/router/tab_shell.dart index 8eb1f5c..ac8b733 100644 --- a/lib/common/router/tab_shell.dart +++ b/lib/common/router/tab_shell.dart @@ -1,11 +1,8 @@ -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}); @@ -44,129 +41,88 @@ class _TabShellState extends State { 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, - ), - ), - ], - ], + 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, ), ), - ), - body: ColoredBox( - color: cs.background, - child: NotificationListener( - 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 (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( + onNotification: _handleScrollNotification, + child: Stack(children: [Positioned.fill(child: widget.child)]), + ), + ), + bottomNavigationBar: DecoratedBox( + decoration: BoxDecoration( + color: cs.background, + border: Border(top: BorderSide(color: cs.border, width: 1)), + ), + child: SafeArea( + top: false, + child: SizedBox( + height: 62, + 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('请求'), - ), - ), - ], + ), ), ); } diff --git a/lib/common/widgets/prototype_ui.dart b/lib/common/widgets/prototype_ui.dart new file mode 100644 index 0000000..d47d571 --- /dev/null +++ b/lib/common/widgets/prototype_ui.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class PrototypeFrame extends StatelessWidget { + const PrototypeFrame({ + super.key, + required this.child, + this.top = 0, + this.bottom = 24, + }); + + final Widget child; + final double top; + final double bottom; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB(18, top, 18, bottom), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: child, + ), + ), + ); + } +} + +class PrototypeCard extends StatelessWidget { + const PrototypeCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + this.margin, + this.radius = 14, + this.decoration, + }); + + final Widget child; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry? margin; + final double radius; + final BoxDecoration? decoration; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Container( + margin: margin, + decoration: decoration ?? + BoxDecoration( + color: cs.card, + border: Border.all(color: cs.border, width: 0.5), + borderRadius: BorderRadius.circular(radius), + ), + child: Padding(padding: padding, child: child), + ); + } +} + +class PrototypePill extends StatelessWidget { + const PrototypePill({ + super.key, + required this.label, + this.selected = false, + this.leading, + this.onTap, + }); + + final String label; + final bool selected; + final Widget? leading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + final bg = selected ? cs.secondary : cs.background; + final fg = selected ? cs.secondaryForeground : cs.mutedForeground; + final borderColor = selected ? const Color(0xFFE3D6B4) : cs.border; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor, width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 4)], + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: fg, + ), + ), + ], + ), + ), + ); + } +} + +class PrototypeMetricChip extends StatelessWidget { + const PrototypeMetricChip({ + super.key, + required this.label, + required this.value, + this.valueColor, + this.background, + }); + + final String label; + final String value; + final Color? valueColor; + final Color? background; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: background ?? cs.background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: cs.border, width: 0.5), + ), + child: RichText( + text: TextSpan( + style: TextStyle(fontSize: 10, color: cs.mutedForeground), + children: [ + TextSpan(text: '$label '), + TextSpan( + text: value, + style: TextStyle( + fontWeight: FontWeight.w600, + color: valueColor ?? cs.foreground, + ), + ), + ], + ), + ), + ); + } +} + +class PrototypeLineChart extends StatelessWidget { + const PrototypeLineChart({ + super.key, + required this.values, + this.lineColor = const Color(0xFFB5862A), + this.fillColor = const Color(0xFFF7EFD9), + }); + + final List values; + final Color lineColor; + final Color fillColor; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _PrototypeLineChartPainter( + values: values, + lineColor: lineColor, + fillColor: fillColor, + ), + size: Size.infinite, + ); + } +} + +class _PrototypeLineChartPainter extends CustomPainter { + _PrototypeLineChartPainter({ + required this.values, + required this.lineColor, + required this.fillColor, + }); + + final List values; + final Color lineColor; + final Color fillColor; + + @override + void paint(Canvas canvas, Size size) { + if (values.isEmpty || size.isEmpty) return; + final paintGrid = Paint() + ..color = const Color(0xFFECEAE3) + ..strokeWidth = 1; + final paintLine = Paint() + ..color = lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2.4 + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round; + final paintFill = Paint() + ..color = fillColor + ..style = PaintingStyle.fill; + const padTop = 10.0; + const padBottom = 10.0; + final plotHeight = size.height - padTop - padBottom; + final plotWidth = size.width; + final min = values.reduce((a, b) => a < b ? a : b); + final max = values.reduce((a, b) => a > b ? a : b); + final span = (max - min).abs() < 0.0001 ? 1.0 : max - min; + Offset pointFor(int index) { + final x = values.length == 1 + ? plotWidth / 2 + : index / (values.length - 1) * plotWidth; + final y = padTop + (1 - (values[index] - min) / span) * plotHeight; + return Offset(x, y); + } + + for (var i = 0; i < 4; i++) { + final dy = padTop + plotHeight * i / 3; + canvas.drawLine(Offset(0, dy), Offset(size.width, dy), paintGrid); + } + + final pts = List.generate(values.length, pointFor); + final path = Path()..moveTo(pts.first.dx, pts.first.dy); + for (final p in pts.skip(1)) { + path.lineTo(p.dx, p.dy); + } + final fillPath = Path() + ..addPath(path, Offset.zero) + ..lineTo(size.width, size.height) + ..lineTo(0, size.height) + ..close(); + canvas.drawPath(fillPath, paintFill); + canvas.drawPath(path, paintLine); + final last = pts.last; + canvas.drawCircle(last, 3.8, Paint()..color = lineColor); + } + + @override + bool shouldRepaint(covariant _PrototypeLineChartPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.lineColor != lineColor || + oldDelegate.fillColor != fillColor; + } +} + +class PrototypeSectionLabel extends StatelessWidget { + const PrototypeSectionLabel(this.label, {super.key, this.trailing}); + + final String label; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + const Spacer(), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +class PrototypeDivider extends StatelessWidget { + const PrototypeDivider({super.key, this.margin = const EdgeInsets.symmetric(vertical: 10)}); + + final EdgeInsetsGeometry margin; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Container( + margin: margin, + height: 0.5, + color: cs.border, + ); + } +} diff --git a/lib/features/assets/presentation/assets_page.dart b/lib/features/assets/presentation/assets_page.dart index ff2226c..2c9ece8 100644 --- a/lib/features/assets/presentation/assets_page.dart +++ b/lib/features/assets/presentation/assets_page.dart @@ -1,10 +1,16 @@ 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/domain/metal_type.dart'; import '../../../common/domain/money.dart'; -import '../../../common/widgets/adaptive.dart'; +import '../../../common/router/app_router.dart'; +import '../../../common/widgets/prototype_ui.dart'; import '../application/asset_portfolio_controller.dart'; +import '../data/asset_models.dart'; +import '../../market/application/market_controller.dart'; +import '../../profile/application/profile_settings_controller.dart'; class AssetsPage extends ConsumerWidget { const AssetsPage({super.key}); @@ -12,99 +18,383 @@ class AssetsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final summary = ref.watch(assetPortfolioControllerProvider); + final settings = ref.watch(profileSettingsControllerProvider); final cs = ShadTheme.of(context).colorScheme; + final hidden = settings.amountHidden; + return SafeArea( top: false, child: ListView( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 24), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 86), children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: Adaptive.contentMaxWidth, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Row( children: [ - Text('我的贵金属总估值', style: _mutedStyle(cs)), - const SizedBox(height: 10), + _Dot(), + SizedBox(width: 8), 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), + fontSize: 17, + fontWeight: FontWeight.w600, ), ), ], ), + const Spacer(), + IconButton( + onPressed: () => _showAddAssetSheet(context), + icon: const Icon(Icons.add, size: 22), + color: const Color(0xFF9A968D), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 30, + height: 30, + ), + ), + IconButton( + onPressed: () => ref + .read(profileSettingsControllerProvider.notifier) + .toggleAmountHidden(), + icon: Icon( + hidden ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: 20, + ), + color: const Color(0xFF9A968D), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 30, + height: 30, + ), + ), + ], + ), + const SizedBox(height: 26), + Text( + '我的贵金属总估值', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: cs.mutedForeground), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 11), + child: Text( + '¥', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 3), + Text( + hidden ? '••••••' : formatCny(summary.totalValue).replaceFirst('¥', ''), + style: const TextStyle( + fontSize: 64, + fontWeight: FontWeight.w600, + height: 1, + letterSpacing: -2, + ), + ), + ], + ), + const SizedBox(height: 15), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ChangeChip( + amount: summary.todayChangeAmount, + percent: summary.todayChangePercent, + ), + const SizedBox(width: 10), + Text( + '今日', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + ], + ), + const SizedBox(height: 14), + SizedBox( + height: 32, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: summary.breakdowns.length, + separatorBuilder: (context, index) => const SizedBox(width: 6), + itemBuilder: (context, index) { + final item = summary.breakdowns[index]; + return PrototypePill( + label: '${item.metal.shortLabel} ${hidden ? '•••' : formatCny(item.value)}', + leading: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: item.metal.color, + shape: BoxShape.circle, + ), + ), + ); + }, ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, + ), + const SizedBox(height: 14), + Text( + '更新于 16:11 · 行情聚合', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: cs.mutedForeground.withValues(alpha: 0.82)), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () => context.push( + '${AppRoutes.marketKline}?metal=gold&period=24h', + ), + child: SizedBox( + height: 46, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: PrototypeLineChart( + values: const [27, 31, 29, 35, 33, 37, 42, 40, 46, 44, 50, 53], + ), + ), + ), + ), + const SizedBox(height: 14), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 1.6, + children: [ + _StatCard(title: '回收参考', value: hidden ? '¥••••' : formatCny(summary.recycleReferenceValue)), + _StatCard( + title: '总盈亏(已填成本)', + value: hidden + ? '••••' + : _signedCny(summary.totalValue - summary.totalCost), + valueColor: summary.totalValue - summary.totalCost >= 0 + ? const Color(0xFFC0392B) + : const Color(0xFF2E8B6F), + ), + _StatCard(title: '总成本', value: hidden ? '¥••••' : formatCny(summary.totalCost)), + _StatCard( + title: '总克重', + value: '${summary.totalWeightGram.toStringAsFixed(1)} g', + ), + ], + ), + const SizedBox(height: 12), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( children: [ - for (final item in summary.breakdowns) - Chip( - label: Text( - '${item.metal.shortLabel} ${formatCny(item.value)}', - ), - avatar: CircleAvatar( - backgroundColor: item.metal.color, + Padding( + padding: const EdgeInsets.fromLTRB(16, 15, 16, 8), + child: Row( + children: [ + const Text( + '我的持仓', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + '${summary.holdings.length} 件 · ${summary.totalWeightGram.toStringAsFixed(1)}g', + style: TextStyle( + fontSize: 12, + color: cs.mutedForeground, + ), + ), + ], + ), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + for (final item in summary.holdings) ...[ + _HoldingRow( + valuation: item, + hidden: hidden, + onTap: () => context.push( + '${AppRoutes.assetDetail}?id=${item.holding.id}', ), ), - ], - ), - 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)), - ], + if (item != summary.holdings.last) + const PrototypeDivider(margin: EdgeInsets.zero), + ], + const PrototypeDivider(margin: EdgeInsets.zero), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _showAddAssetSheet(context), + icon: const Icon(Icons.add, size: 18), + label: const Text('添加资产'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF9A7A2A), + side: const BorderSide(color: Color(0xFFE3D6B4)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), ), - ], - ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _showAddAssetSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + builder: (ctx) => const _AddAssetSheet(), + ); + } + + String _signedCny(double value) { + final sign = value >= 0 ? '+' : '-'; + return '$sign${formatCny(value.abs())}'; + } +} + +class _HoldingRow extends StatelessWidget { + const _HoldingRow({ + required this.valuation, + required this.hidden, + required this.onTap, + }); + + final HoldingValuation valuation; + final bool hidden; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final holding = valuation.holding; + final metal = holding.metal; + final change = valuation.todayChange; + final changeUp = change >= 0; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: metal.color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(_iconFor(holding.category), size: 18, color: metal.color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + holding.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + '${holding.purityLabel} · ${holding.weightGram.toStringAsFixed(1)}g' + '${holding.note == null ? '' : ' · ${holding.note}'}', + style: const TextStyle(fontSize: 12, color: Color(0xFF8A8780)), ), ], ), ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + hidden ? '¥••••' : formatCny(valuation.materialValue), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + '${changeUp ? '↑' : '↓'}${hidden ? '¥••••' : formatCny(change.abs())}', + style: TextStyle( + fontSize: 12, + color: changeUp ? const Color(0xFFC0392B) : const Color(0xFF2E8B6F), + ), + ), + ], + ), + ], + ), + ), + ); + } + + IconData _iconFor(AssetCategory category) { + return switch (category) { + AssetCategory.bar || AssetCategory.bean => Icons.auto_awesome, + AssetCategory.ring => Icons.radio_button_unchecked, + AssetCategory.necklace => Icons.circle_outlined, + AssetCategory.bracelet => Icons.watch_outlined, + AssetCategory.earring => Icons.circle, + AssetCategory.silverware => Icons.circle, + AssetCategory.platinumPiece => Icons.circle, + }; + } +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.title, + required this.value, + this.valueColor, + }); + + final String title; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return PrototypeCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: TextStyle(fontSize: 12, color: cs.mutedForeground)), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: valueColor ?? cs.foreground, + ), ), ], ), @@ -112,25 +402,550 @@ class AssetsPage extends ConsumerWidget { } } -class _Card extends StatelessWidget { - const _Card({required this.child}); +class _ChangeChip extends StatelessWidget { + const _ChangeChip({required this.amount, required this.percent}); - final Widget child; + final double amount; + final double percent; @override Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - return DecoratedBox( + final positive = amount >= 0; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), decoration: BoxDecoration( - color: cs.card, - border: Border.all(color: cs.border), - borderRadius: BorderRadius.circular(12), + color: positive ? const Color(0xFFFBEDEA) : const Color(0xFFE7F2EE), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${positive ? '↑' : '↓'}${formatCny(amount.abs())} · ${positive ? '+' : '-'}${percent.abs().toStringAsFixed(2)}%', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: positive ? const Color(0xFFC0392B) : const Color(0xFF2E8B6F), + ), ), - child: Padding(padding: const EdgeInsets.all(16), child: child), ); } } -TextStyle _mutedStyle(ShadColorScheme cs) { - return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0); +class _Dot extends StatelessWidget { + const _Dot(); + + @override + Widget build(BuildContext context) { + return Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFB5862A), + shape: BoxShape.circle, + ), + ); + } +} + +class _AddAssetSheet extends ConsumerStatefulWidget { + const _AddAssetSheet(); + + @override + ConsumerState<_AddAssetSheet> createState() => _AddAssetSheetState(); +} + +class _AddAssetSheetState extends ConsumerState<_AddAssetSheet> { + static const _cats = [ + AssetCategory.bar, + AssetCategory.necklace, + AssetCategory.ring, + AssetCategory.bracelet, + AssetCategory.bean, + AssetCategory.earring, + AssetCategory.silverware, + AssetCategory.platinumPiece, + ]; + + static const _metalPurities = { + MetalType.gold: ['足金9999', '足金999', '22K', '18K'], + MetalType.platinum: ['PT990', 'PT950'], + MetalType.silver: ['999银', '925银'], + }; + + AssetCategory _cat = AssetCategory.bar; + MetalType _metal = MetalType.gold; + int _purityIndex = 0; + double _gram = 50; + bool _expanded = false; + final _costCtrl = TextEditingController(); + final _noteCtrl = TextEditingController(); + final _dateCtrl = TextEditingController(text: '2025-08-12'); + final _channelCtrl = TextEditingController(text: '银行'); + + @override + void dispose() { + _costCtrl.dispose(); + _noteCtrl.dispose(); + _dateCtrl.dispose(); + _channelCtrl.dispose(); + super.dispose(); + } + + List get _purityOptions => _metalPurities[_metal]!; + + String get _purity => _purityOptions[_purityIndex.clamp(0, _purityOptions.length - 1)]; + + void _cyclePurity() { + setState(() { + _purityIndex = (_purityIndex + 1) % _purityOptions.length; + }); + } + + double _factorFor(String purity) { + return switch (purity) { + '足金9999' => 0.9999, + '足金999' => 0.999, + '22K' => 0.916, + '18K' => 0.75, + 'PT990' => 0.99, + 'PT950' => 0.95, + '999银' => 0.999, + '925银' => 0.925, + _ => 1, + }; + } + + String _nameForSelection() { + return switch (_cat) { + AssetCategory.bar => '投资金条', + AssetCategory.necklace => '${_metal.label}项链', + AssetCategory.ring => '${_metal.label}戒指', + AssetCategory.bracelet => '${_metal.label}手镯', + AssetCategory.bean => '金豆', + AssetCategory.earring => '${_metal.label}耳环', + AssetCategory.silverware => '银饰', + AssetCategory.platinumPiece => 'PT饰品', + }; + } + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + final market = ref.watch(marketControllerProvider); + final basePrice = market.quote.spotPrice; + final value = _gram * _factorFor(_purity) * basePrice; + + return SafeArea( + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 38, + height: 4, + margin: const EdgeInsets.only(bottom: 14), + decoration: BoxDecoration( + color: const Color(0xFFE3DFD4), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + Row( + children: [ + const Spacer(), + const Text( + '添加资产', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon(Icons.close, size: 20, color: Color(0xFF8A8780)), + ), + ], + ), + const SizedBox(height: 16), + const Text('选择品类', style: TextStyle(fontSize: 13, color: Color(0xFF8A8780))), + const SizedBox(height: 10), + Wrap( + spacing: 9, + runSpacing: 9, + children: [ + for (final cat in _cats) + _Chip( + label: cat.label, + selected: cat == _cat, + onTap: () => setState(() { + _cat = cat; + if (cat == AssetCategory.silverware) { + _metal = MetalType.silver; + } else if (cat == AssetCategory.platinumPiece) { + _metal = MetalType.platinum; + } else { + _metal = MetalType.gold; + } + _purityIndex = 0; + }), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.fromLTRB(16, 13, 16, 13), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE3D6B4), width: 0.5), + borderRadius: BorderRadius.circular(13), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('金属 / 纯度', style: TextStyle(fontSize: 13, color: Color(0xFF8A8780))), + GestureDetector( + onTap: _cyclePurity, + child: Row( + children: [ + Text( + '${_metal.label} · $_purity', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 7), + const Icon(Icons.unfold_more, size: 16, color: Color(0xFFB3AFA5)), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const Text('克重(克)', style: TextStyle(fontSize: 13, color: Color(0xFF8A8780))), + const SizedBox(height: 9), + Row( + children: [ + _StepButton( + icon: Icons.remove, + onTap: () => setState(() => _gram = (_gram - 1).clamp(1, 200)), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE3D6B4), width: 0.5), + borderRadius: BorderRadius.circular(13), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _gram.toStringAsFixed(_gram % 1 == 0 ? 0 : 1), + style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w600, height: 1), + ), + const SizedBox(width: 6), + const Padding( + padding: EdgeInsets.only(bottom: 2), + child: Text('克', style: TextStyle(fontSize: 15, color: Color(0xFF8A8780))), + ), + ], + ), + ), + ), + const SizedBox(width: 12), + _StepButton( + icon: Icons.add, + onTap: () => setState(() => _gram = (_gram + 1).clamp(1, 200)), + ), + ], + ), + Slider( + min: 1, + max: 200, + value: _gram.clamp(1, 200), + onChanged: (value) => setState(() => _gram = value), + activeColor: const Color(0xFFB5862A), + inactiveColor: const Color(0xFFECEAE3), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.fromLTRB(16, 15, 16, 16), + decoration: BoxDecoration( + color: const Color(0xFFF6ECD6), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('实时材料价值', style: TextStyle(fontSize: 13, color: Color(0xFF7A5B12))), + Text( + formatCny(value), + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 7), + Text( + '按${_metal.label}现货 ${basePrice.toStringAsFixed(2)}/克 · 非金店零售价、非回收实收价', + style: const TextStyle(fontSize: 11, color: Color(0xFF9A7A2A)), + ), + ], + ), + ), + const SizedBox(height: 14), + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 13, 16, 13), + decoration: BoxDecoration( + color: cs.card, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: cs.border, width: 0.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '选填:买入成本 · 日期 · 渠道 · 备注 · 照片', + style: TextStyle(fontSize: 13, color: cs.mutedForeground), + ), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + color: cs.mutedForeground, + size: 18, + ), + ], + ), + ), + ), + if (_expanded) ...[ + const SizedBox(height: 12), + _FieldRow( + label: '买入成本(含工费)', + child: TextField( + controller: _costCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: '选填', + ), + textAlign: TextAlign.right, + ), + ), + const SizedBox(height: 10), + _FieldRow( + label: '购买日期', + child: TextField( + controller: _dateCtrl, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + ), + textAlign: TextAlign.right, + ), + ), + const SizedBox(height: 10), + _FieldRow( + label: '购买渠道', + child: Wrap( + spacing: 7, + runSpacing: 7, + alignment: WrapAlignment.end, + children: [ + for (final channel in ['银行', '金店', '电商', '回收市场', '其他']) + _Chip( + label: channel, + selected: _channelCtrl.text == channel, + onTap: () => setState(() => _channelCtrl.text = channel), + ), + ], + ), + ), + const SizedBox(height: 10), + _FieldRow( + label: '备注', + child: TextField( + controller: _noteCtrl, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: '点击填写', + ), + textAlign: TextAlign.right, + ), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEAE3), width: 0.5), + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(11), + border: Border.all(color: const Color(0xFFD3CDBE), width: 0.5), + ), + child: const Icon(Icons.camera_alt_outlined, color: Color(0xFFB3AFA5)), + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + '添加发票 / 实物照片(选填,仅存本机)', + style: TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFE3D6B4)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('取消'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: () { + final holding = GoldAssetHolding( + id: 'h${DateTime.now().microsecondsSinceEpoch}', + name: _nameForSelection(), + metal: _metal, + category: _cat, + purity: _factorFor(_purity), + purityLabel: _purity, + weightGram: _gram, + costAmount: double.tryParse(_costCtrl.text.trim()), + purchaseDate: DateTime.tryParse(_dateCtrl.text.trim()), + channel: _channelCtrl.text.trim().isEmpty ? null : _channelCtrl.text.trim(), + note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + ref.read(assetPortfolioControllerProvider.notifier).addHolding(holding); + Navigator.of(context).pop(); + }, + child: const Text('保存'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), + decoration: BoxDecoration( + color: selected ? const Color(0xFFF6ECD6) : Colors.white, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: selected ? const Color(0xFFE3D6B4) : const Color(0xFFECEAE3), + width: 0.5, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? const Color(0xFF7A5B12) : const Color(0xFF5F5E5A), + ), + ), + ), + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({required this.icon, required this.onTap}); + + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE3D6B4), width: 0.5), + ), + child: Icon(icon, size: 18, color: const Color(0xFFB5862A)), + ), + ); + } +} + +class _FieldRow extends StatelessWidget { + const _FieldRow({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEAE3), width: 0.5), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8A8780))), + const SizedBox(width: 10), + Expanded(child: child), + ], + ), + ); + } } diff --git a/lib/features/assets/presentation/holding_detail_page.dart b/lib/features/assets/presentation/holding_detail_page.dart new file mode 100644 index 0000000..afbc300 --- /dev/null +++ b/lib/features/assets/presentation/holding_detail_page.dart @@ -0,0 +1,262 @@ +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/prototype_ui.dart'; +import '../application/asset_portfolio_controller.dart'; + +class HoldingDetailPage extends ConsumerWidget { + const HoldingDetailPage({super.key, this.id}); + + final String? id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final summary = ref.watch(assetPortfolioControllerProvider); + final holding = summary.holdings + .map((item) => item.holding) + .firstWhere( + (item) => id == null || item.id == id, + orElse: () => summary.holdings.first.holding, + ); + final valuation = summary.holdings + .firstWhere((item) => item.holding.id == holding.id); + final cs = ShadTheme.of(context).colorScheme; + final changeUp = valuation.todayChange >= 0; + + return Scaffold( + backgroundColor: const Color(0xFFFBFAF7), + appBar: AppBar( + backgroundColor: const Color(0xFFFBFAF7), + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + leading: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.chevron_left, size: 20), + ), + title: Text( + holding.name, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 28), + children: [ + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + PrototypeCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: holding.metal.color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.circle_outlined, color: holding.metal.color, size: 18), + ), + const SizedBox(width: 10), + Text( + '${holding.purityLabel} · ${holding.weightGram.toStringAsFixed(1)}g', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '¥', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 2), + Text( + formatCny(valuation.materialValue).replaceFirst('¥', ''), + style: const TextStyle( + fontSize: 42, + fontWeight: FontWeight.w600, + letterSpacing: -1, + height: 1, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _ChangeChip( + amount: valuation.todayChange, + positive: changeUp, + ), + const SizedBox(width: 8), + Text( + '今日', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _InfoRow(label: '材料价值(现货)', value: formatCny(valuation.materialValue)), + const PrototypeDivider(margin: EdgeInsets.zero), + _InfoRow(label: '回收参考价', value: formatCny(valuation.materialValue * 0.97)), + if (holding.costAmount != null) ...[ + const PrototypeDivider(margin: EdgeInsets.zero), + _InfoRow(label: '买入成本', value: formatCny(holding.costAmount!)), + const PrototypeDivider(margin: EdgeInsets.zero), + _InfoRow( + label: '盈亏', + value: _signedCny(valuation.materialValue - holding.costAmount!), + valueColor: valuation.materialValue - holding.costAmount! >= 0 + ? const Color(0xFFC0392B) + : const Color(0xFF2E8B6F), + ), + ], + ], + ), + ), + const SizedBox(height: 12), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _InfoRow(label: '金属 / 纯度', value: '${holding.metal.label} · ${holding.purityLabel}'), + const PrototypeDivider(margin: EdgeInsets.zero), + _InfoRow(label: '克重', value: '${holding.weightGram.toStringAsFixed(1)} g'), + const PrototypeDivider(margin: EdgeInsets.zero), + _InfoRow(label: '购买日期', value: _dateLabel(holding.purchaseDate)), + const PrototypeDivider(margin: EdgeInsets.zero), + _InfoRow(label: '购买渠道', value: holding.channel ?? '未填'), + ], + ), + ), + const SizedBox(height: 12), + PrototypeCard( + child: GestureDetector( + onTap: () {}, + child: SizedBox( + height: 156, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: PrototypeLineChart( + values: const [18, 20, 21, 23, 22, 25, 29, 31, 30, 33, 35, 37], + ), + ), + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _showAction(context, '编辑'), + child: const Text('编辑'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton( + onPressed: () => _showAction(context, '标记卖出 / 转赠'), + child: const Text('标记卖出 / 转赠'), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _showAction(BuildContext context, String text) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$text 占位'))); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.label, + required this.value, + this.valueColor, + }); + + final String label; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(fontSize: 13, color: cs.mutedForeground)), + Text( + value, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: valueColor ?? cs.foreground), + ), + ], + ), + ); + } +} + +class _ChangeChip extends StatelessWidget { + const _ChangeChip({required this.amount, required this.positive}); + + final double amount; + final bool positive; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: positive ? const Color(0xFFFBEDEA) : const Color(0xFFE7F2EE), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${positive ? '↑' : '↓'}${formatCny(amount.abs())} · ${positive ? '+' : '-'}${amount.abs().toStringAsFixed(2)}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: positive ? const Color(0xFFC0392B) : const Color(0xFF2E8B6F), + ), + ), + ); + } +} + +String _dateLabel(DateTime? value) { + if (value == null) return '未填'; + return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; +} + +String _signedCny(double value) { + final sign = value >= 0 ? '+' : '-'; + return '$sign${formatCny(value.abs())}'; +} diff --git a/lib/features/market/presentation/market_kline_page.dart b/lib/features/market/presentation/market_kline_page.dart new file mode 100644 index 0000000..d73ed6d --- /dev/null +++ b/lib/features/market/presentation/market_kline_page.dart @@ -0,0 +1,194 @@ +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/widgets/prototype_ui.dart'; +import '../data/mock_market_repository.dart'; +import '../data/market_models.dart'; + +class MarketKlinePage extends ConsumerWidget { + const MarketKlinePage({super.key, this.metalId, this.periodId}); + + final String? metalId; + final String? periodId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repo = ref.watch(mockMarketRepositoryProvider); + final metal = _metalFromId(metalId) ?? MetalType.gold; + final period = _periodFromId(periodId) ?? MarketPeriod.h24; + final quote = repo.quoteFor(metal); + final points = repo.chartFor(metal, period); + final cs = ShadTheme.of(context).colorScheme; + + return Scaffold( + backgroundColor: const Color(0xFFFBFAF7), + appBar: AppBar( + backgroundColor: const Color(0xFFFBFAF7), + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + leading: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.chevron_left, size: 20), + ), + title: Text( + '${metal.label} K线', + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), + children: [ + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + PrototypeCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '¥', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + const SizedBox(width: 2), + Text( + quote.spotPrice.toStringAsFixed(2), + style: const TextStyle( + fontSize: 29, + fontWeight: FontWeight.w600, + letterSpacing: -1, + ), + ), + const SizedBox(width: 4), + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text('元/克', style: TextStyle(fontSize: 12, color: Color(0xFF8A8780))), + ), + ], + ), + const SizedBox(height: 6), + Text( + '${quote.changeAmount >= 0 ? '+' : ''}${quote.changeAmount.toStringAsFixed(2)} · ${quote.changePercent.toStringAsFixed(2)}%', + style: TextStyle( + color: quote.changeAmount >= 0 + ? const Color(0xFFC0392B) + : const Color(0xFF2E8B6F), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final p in MarketPeriod.values) + PrototypePill( + label: p.label, + selected: p == period, + onTap: () => Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => MarketKlinePage( + metalId: metal.id, + periodId: p.id, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _Ohlc('开', points.first.open), + const SizedBox(width: 18), + _Ohlc('高', points.map((e) => e.high).reduce((a, b) => a > b ? a : b)), + const SizedBox(width: 18), + _Ohlc('低', points.map((e) => e.low).reduce((a, b) => a < b ? a : b)), + const SizedBox(width: 18), + _Ohlc('收', points.last.close), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 300, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: PrototypeLineChart( + values: points.map((p) => p.close).toList(growable: false), + ), + ), + ), + const SizedBox(height: 10), + Text( + '${quote.basisLabel} · ${_timeLabel(quote.updatedAt)}', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _Ohlc extends StatelessWidget { + const _Ohlc(this.label, this.value); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF8A8780))), + const SizedBox(height: 2), + Text(value.toStringAsFixed(2), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ], + ); + } +} + +MetalType? _metalFromId(String? id) { + return switch (id) { + 'gold' => MetalType.gold, + 'platinum' => MetalType.platinum, + 'silver' => MetalType.silver, + _ => null, + }; +} + +MarketPeriod? _periodFromId(String? id) { + return switch (id) { + '24h' => MarketPeriod.h24, + '5d' => MarketPeriod.d5, + '1m' => MarketPeriod.m1, + '3m' => MarketPeriod.m3, + '1y' => MarketPeriod.y1, + _ => null, + }; +} + +String _timeLabel(DateTime value) { + return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; +} diff --git a/lib/features/market/presentation/market_page.dart b/lib/features/market/presentation/market_page.dart index c0e8c3c..6ddfd48 100644 --- a/lib/features/market/presentation/market_page.dart +++ b/lib/features/market/presentation/market_page.dart @@ -1,149 +1,314 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.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 '../../../common/router/app_router.dart'; +import '../../../common/widgets/prototype_ui.dart'; import '../application/exchange_calculator_controller.dart'; import '../application/market_controller.dart'; import '../data/market_models.dart'; -class MarketPage extends ConsumerWidget { +class MarketPage extends HookConsumerWidget { const MarketPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final market = ref.watch(marketControllerProvider); final exchange = ref.watch(exchangeCalculatorControllerProvider); + final gramCtrl = useTextEditingController(text: exchange.gram.toStringAsFixed(2)); + final amountCtrl = useTextEditingController(text: exchange.amount.toStringAsFixed(2)); + final gramFocus = useFocusNode(); + final amountFocus = useFocusNode(); final cs = ShadTheme.of(context).colorScheme; + + useEffect(() { + if (!gramFocus.hasFocus) { + gramCtrl.text = exchange.gram.toStringAsFixed( + exchange.gram % 1 == 0 ? 0 : 2, + ); + } + if (!amountFocus.hasFocus) { + amountCtrl.text = exchange.amount.toStringAsFixed(2); + } + return null; + }, [exchange.gram, exchange.amount, exchange.selectedChannel.id, exchange.metal.id]); + return SafeArea( top: false, child: ListView( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 24), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 86), children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: Adaptive.contentMaxWidth, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SegmentedButton( - 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); + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text( + '行情', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + const Spacer(), + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: const Color(0xFFF7F4EE), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.notifications_none_outlined, + size: 18, + color: Color(0xFF9A968D), + ), + ), + ], + ), + const SizedBox(height: 14), + SizedBox( + height: 32, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: MetalType.values.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final metal = MetalType.values[index]; + return PrototypePill( + label: metal.label, + selected: metal == market.selectedMetal, + onTap: () { + 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: 10), + PrototypeCard( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 13), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '¥', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + 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(width: 2), + Text( + formatCny(market.quote.spotPrice).replaceFirst('¥', ''), + style: TextStyle( + fontSize: 29, + fontWeight: FontWeight.w600, + color: cs.foreground, + letterSpacing: -1, + height: 1, + ), ), - ), - 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 Padding( + padding: EdgeInsets.only(left: 4, bottom: 2), + child: Text( + '元/克', + style: TextStyle(fontSize: 12, color: Color(0xFF8A8780)), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + _ChangeChip(amount: market.quote.changeAmount, percent: market.quote.changePercent), + const SizedBox(width: 8), + Text( + '报价 ${_timeLabel(market.quote.updatedAt)}', + style: const TextStyle(fontSize: 12, color: Color(0xFF8A8780)), + ), + const Spacer(), + GestureDetector( + onTap: () {}, + child: const Icon(Icons.info_outline, size: 16, color: Color(0xFFB3AFA5)), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + for (final period in MarketPeriod.values) ...[ + _PeriodPill( + label: period.label, + selected: period == market.period, + onTap: () => ref.read(marketControllerProvider.notifier).selectPeriod(period), + ), + if (period != MarketPeriod.values.last) const SizedBox(width: 6), ], + ], + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () => context.push( + '${AppRoutes.marketKline}?metal=${market.selectedMetal.id}&period=${market.period.id}', ), - const SizedBox(height: 14), - Text( - '走势图 mock 点位:${market.points.length} 个', - style: _mutedStyle(cs), + child: SizedBox( + height: 88, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: PrototypeLineChart( + values: market.points.map((p) => p.close).toList(growable: false), + ), + ), + ), + ), + const SizedBox(height: 10), + Container(height: 0.5, color: cs.border), + const SizedBox(height: 10), + Row( + children: [ + const Text( + '兑换试算', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const Spacer(), + const PrototypePill(label: '多渠道参考', selected: true), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 32, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: exchange.channels.length, + separatorBuilder: (context, index) => const SizedBox(width: 7), + itemBuilder: (context, index) { + final channel = exchange.channels[index]; + return PrototypePill( + label: channel.name, + selected: channel.id == exchange.selectedChannel.id, + onTap: () => ref + .read(exchangeCalculatorControllerProvider.notifier) + .selectChannel(channel.id), + ); + }, + ), + ), + const SizedBox(height: 10), + if (!exchange.swapped) ...[ + _ExchangeField( + controller: gramCtrl, + focusNode: gramFocus, + unit: exchange.metal.label, + leading: '克', + onChanged: (value) { + final gram = double.tryParse(value) ?? 0; + ref.read(exchangeCalculatorControllerProvider.notifier).setGram(gram); + }, + ), + const SizedBox(height: 8), + Center( + child: GestureDetector( + onTap: () => ref.read(exchangeCalculatorControllerProvider.notifier).toggleSwapped(), + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: cs.border, width: 0.5), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Color(0x1F281F0C), + blurRadius: 7, + offset: Offset(0, 2), + ), + ], + ), + child: const Icon(Icons.swap_vert, size: 16, color: Color(0xFFB5862A)), + ), + ), + ), + const SizedBox(height: 8), + _ExchangeField( + controller: amountCtrl, + focusNode: amountFocus, + unit: 'CNY', + leading: '¥', + onChanged: (value) { + final amount = double.tryParse(value) ?? 0; + ref.read(exchangeCalculatorControllerProvider.notifier).setAmount(amount); + }, + ), + ] else ...[ + _ExchangeField( + controller: amountCtrl, + focusNode: amountFocus, + unit: 'CNY', + leading: '¥', + onChanged: (value) { + final amount = double.tryParse(value) ?? 0; + ref.read(exchangeCalculatorControllerProvider.notifier).setAmount(amount); + }, + ), + const SizedBox(height: 8), + Center( + child: GestureDetector( + onTap: () => ref.read(exchangeCalculatorControllerProvider.notifier).toggleSwapped(), + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: cs.border, width: 0.5), + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Color(0x1F281F0C), + blurRadius: 7, + offset: Offset(0, 2), + ), + ], + ), + child: const Icon(Icons.swap_vert, size: 16, color: Color(0xFFB5862A)), + ), + ), + ), + const SizedBox(height: 8), + _ExchangeField( + controller: gramCtrl, + focusNode: gramFocus, + unit: exchange.metal.label, + leading: '克', + onChanged: (value) { + final gram = double.tryParse(value) ?? 0; + ref.read(exchangeCalculatorControllerProvider.notifier).setGram(gram); + }, ), ], - ), + const SizedBox(height: 10), + Text( + '1 克 ${exchange.metal.label} = ${formatCny(exchange.selectedChannel.pricePerGram)} · ${exchange.selectedChannel.source} · 参考', + style: const TextStyle(fontSize: 12, color: Color(0xFF6E6A60)), + ), + const SizedBox(height: 5), + Text( + exchange.selectedChannel.hint, + style: const TextStyle(fontSize: 11, color: Color(0xFFB3AFA5), height: 1.5), + ), + ], ), - 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), - ), - ], - ), - ), - ], - ), + ), + ], ), ), ], @@ -152,25 +317,100 @@ class MarketPage extends ConsumerWidget { } } -class _Card extends StatelessWidget { - const _Card({required this.child}); +class _ExchangeField extends StatelessWidget { + const _ExchangeField({ + required this.controller, + required this.focusNode, + required this.unit, + required this.leading, + required this.onChanged, + }); - final Widget child; + final TextEditingController controller; + final FocusNode focusNode; + final String unit; + final String leading; + final ValueChanged onChanged; @override Widget build(BuildContext context) { - final cs = ShadTheme.of(context).colorScheme; - return DecoratedBox( + return Container( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), decoration: BoxDecoration( - color: cs.card, - border: Border.all(color: cs.border), - borderRadius: BorderRadius.circular(12), + color: Colors.white, + borderRadius: BorderRadius.circular(13), + border: Border.all(color: const Color(0xFFE3D6B4), width: 0.5), + ), + child: Row( + children: [ + Text(leading, style: const TextStyle(fontSize: 14, color: Color(0xFF8A8780))), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.right, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600), + onChanged: onChanged, + ), + ), + const SizedBox(width: 8), + Text( + unit, + style: const TextStyle(fontSize: 14, color: Color(0xFF8A8780)), + ), + ], ), - child: Padding(padding: const EdgeInsets.all(16), child: child), ); } } -TextStyle _mutedStyle(ShadColorScheme cs) { - return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0); +class _PeriodPill extends StatelessWidget { + const _PeriodPill({required this.label, required this.selected, required this.onTap}); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return PrototypePill(label: label, selected: selected, onTap: onTap); + } +} + +class _ChangeChip extends StatelessWidget { + const _ChangeChip({required this.amount, required this.percent}); + + final double amount; + final double percent; + + @override + Widget build(BuildContext context) { + final positive = amount >= 0; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: positive ? const Color(0xFFFBEDEA) : const Color(0xFFE7F2EE), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${positive ? '↑' : '↓'}¥${amount.abs().toStringAsFixed(2)} · ${positive ? '+' : '-'}${percent.abs().toStringAsFixed(2)}%', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: positive ? const Color(0xFFC0392B) : const Color(0xFF2E8B6F), + ), + ), + ); + } +} + +String _timeLabel(DateTime value) { + return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; } diff --git a/lib/features/news/presentation/news_detail_page.dart b/lib/features/news/presentation/news_detail_page.dart new file mode 100644 index 0000000..0f8e111 --- /dev/null +++ b/lib/features/news/presentation/news_detail_page.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../common/widgets/prototype_ui.dart'; +import '../application/news_controller.dart'; + +class NewsDetailPage extends ConsumerWidget { + const NewsDetailPage({super.key, this.id}); + + final String? id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final news = ref.watch(newsControllerProvider).items; + final item = news.firstWhere( + (item) => id == null || item.id == id, + orElse: () => news.first, + ); + final cs = ShadTheme.of(context).colorScheme; + + return Scaffold( + backgroundColor: const Color(0xFFFBFAF7), + appBar: AppBar( + backgroundColor: const Color(0xFFFBFAF7), + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + leading: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.chevron_left, size: 20), + ), + title: Text( + item.metal.label, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), + children: [ + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + PrototypeCard( + 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.label, + style: TextStyle( + color: item.metal.color, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + Text( + '${item.source} · ${_timeLabel(item.publishedAt)}', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + ], + ), + const SizedBox(height: 12), + Text( + item.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, height: 1.4), + ), + const SizedBox(height: 12), + for (final paragraph in item.body) ...[ + Text( + paragraph, + style: TextStyle( + fontSize: 15, + height: 1.7, + color: cs.foreground, + ), + ), + const SizedBox(height: 12), + ], + Text( + '本资讯为原型示意内容,信息整理 · 非投资建议。', + style: TextStyle(fontSize: 12, color: cs.mutedForeground, height: 1.5), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +String _timeLabel(DateTime value) { + return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; +} diff --git a/lib/features/news/presentation/news_page.dart b/lib/features/news/presentation/news_page.dart index 38383e8..6beadc9 100644 --- a/lib/features/news/presentation/news_page.dart +++ b/lib/features/news/presentation/news_page.dart @@ -1,8 +1,10 @@ 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/widgets/adaptive.dart'; +import '../../../common/router/app_router.dart'; +import '../../../common/widgets/prototype_ui.dart'; import '../application/news_controller.dart'; class NewsPage extends ConsumerWidget { @@ -15,104 +17,91 @@ class NewsPage extends ConsumerWidget { return SafeArea( top: false, child: ListView( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 24), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 86), 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, - ), - ), - ], - ), - ), + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text( + '资讯', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + const Spacer(), + IconButton( + onPressed: () => ref.read(newsControllerProvider.notifier).refresh(), + icon: const Icon(Icons.refresh, size: 19), + color: const Color(0xFF9A968D), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 30, + height: 30, ), ), + ], + ), + const SizedBox(height: 2), + Text( + '信息整理 · 非投资建议', + style: TextStyle(fontSize: 13, color: cs.mutedForeground), + ), + const SizedBox(height: 10), + for (final item in news.items) ...[ + PrototypeCard( + margin: const EdgeInsets.only(bottom: 10), + child: InkWell( + onTap: () => context.push('${AppRoutes.newsDetail}?id=${item.id}'), + 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, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + Text( + '${item.source} · ${_timeLabel(item.publishedAt)}', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + const Spacer(), + Icon(Icons.chevron_right, size: 16, color: cs.mutedForeground), + ], + ), + const SizedBox(height: 10), + Text( + item.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + item.summary, + style: TextStyle( + fontSize: 14, + color: cs.mutedForeground, + height: 1.55, + ), + ), + ], + ), + ), + ), ], - ), + ], ), ), ], @@ -120,3 +109,7 @@ class NewsPage extends ConsumerWidget { ); } } + +String _timeLabel(DateTime value) { + return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; +} diff --git a/lib/features/profile/application/profile_settings_controller.dart b/lib/features/profile/application/profile_settings_controller.dart index 52d2a67..4ea08c4 100644 --- a/lib/features/profile/application/profile_settings_controller.dart +++ b/lib/features/profile/application/profile_settings_controller.dart @@ -40,4 +40,13 @@ class ProfileSettingsController extends StateNotifier { void setPriceColorMode(PriceColorMode mode) { state = state.copyWith(priceColorMode: mode); } + + void setRecycleDiscount(MetalType metal, double value) { + state = state.copyWith( + recycleDiscounts: { + ...state.recycleDiscounts, + metal: value.clamp(0.7, 1.0), + }, + ); + } } diff --git a/lib/features/profile/presentation/profile_action_pages.dart b/lib/features/profile/presentation/profile_action_pages.dart new file mode 100644 index 0000000..22e623b --- /dev/null +++ b/lib/features/profile/presentation/profile_action_pages.dart @@ -0,0 +1,1203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../common/domain/metal_type.dart'; +import '../../../common/domain/money.dart'; +import '../../../common/router/app_router.dart'; +import '../../../common/widgets/adaptive.dart'; +import '../../../common/widgets/circle_back_button.dart'; +import '../../../common/widgets/prototype_ui.dart'; +import '../../auth/application/auth_controller.dart'; +import '../../auth/data/auth_models.dart'; +import '../application/profile_settings_controller.dart'; +import '../data/profile_models.dart'; + +class InviteRewardPage extends StatelessWidget { + const InviteRewardPage({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionScaffold( + title: '邀请好友', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _InviteCodeCard(), + const SizedBox(height: 16), + const PrototypeCard( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Column( + children: [ + _KeyValueLine(label: '已邀请好友', value: '3 人'), + PrototypeDivider(margin: EdgeInsets.zero), + _KeyValueLine( + label: '累计获得奖励', + value: '¥120', + valueColor: Color(0xFFB5862A), + ), + ], + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () => _toast(context, '演示:拉起微信 / 系统分享面板'), + child: const Text('分享给好友'), + ), + const SizedBox(height: 14), + const Text( + '好友通过你的邀请码注册并完成首次记账,双方各得奖励。奖励权益将随产品逐步开放,具体以活动规则为准。', + style: TextStyle( + fontSize: 11, + height: 1.6, + color: Color(0xFFB3AFA5), + ), + ), + ], + ), + ); + } +} + +class ShareCardPage extends ConsumerWidget { + const ShareCardPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + final currentUser = auth.valueOrNull is LoggedInAuthState + ? (auth.valueOrNull as LoggedInAuthState).user + : null; + final nickname = currentUser?.nickname.trim(); + final owner = nickname == null || nickname.isEmpty ? '金值用户' : nickname; + + return _ActionScaffold( + title: '分享估值卡', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SharePreviewCard(owner: owner), + const SizedBox(height: 14), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: const [ + _SettingLikeRow( + icon: Icons.visibility_off_outlined, + title: '金额隐私', + trailing: '已打码', + ), + PrototypeDivider(margin: EdgeInsets.zero), + _SettingLikeRow( + icon: Icons.ios_share_outlined, + title: '分享渠道', + trailing: '系统分享', + ), + ], + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: '$owner 的贵金属估值卡,已通过金值生成。'), + ); + if (!context.mounted) return; + _toast(context, '分享文案已复制'); + }, + child: const Text('复制分享文案'), + ), + ], + ), + ); + } +} + +class LocalBackupPage extends StatelessWidget { + const LocalBackupPage({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionScaffold( + title: '本地导出备份', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: const [ + _SettingLikeRow( + icon: Icons.inventory_2_outlined, + title: '备份内容', + trailing: '资产 / 设置', + ), + PrototypeDivider(margin: EdgeInsets.zero), + _SettingLikeRow( + icon: Icons.description_outlined, + title: '导出格式', + trailing: 'JSON', + ), + PrototypeDivider(margin: EdgeInsets.zero), + _SettingLikeRow( + icon: Icons.lock_outline, + title: '存储位置', + trailing: '仅本机', + ), + ], + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () => _toast(context, '已生成本地备份文件'), + child: const Text('生成备份'), + ), + const SizedBox(height: 12), + const Text( + '备份文件包含资产、纯度系数、回收折扣与显示设置,不包含登录凭证。', + style: TextStyle( + fontSize: 11, + height: 1.6, + color: Color(0xFFB3AFA5), + ), + ), + ], + ), + ); + } +} + +class CloudBackupPage extends ConsumerWidget { + const CloudBackupPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + final loggedIn = auth.valueOrNull is LoggedInAuthState; + + return _ActionScaffold( + title: '云备份 · 多端同步', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _SettingLikeRow( + icon: Icons.cloud_done_outlined, + title: '同步状态', + trailing: loggedIn ? '可开启' : '登录解锁', + ), + const PrototypeDivider(margin: EdgeInsets.zero), + const _SettingLikeRow( + icon: Icons.devices_outlined, + title: '多端同步', + trailing: '资产 / 设置', + ), + const PrototypeDivider(margin: EdgeInsets.zero), + const _SettingLikeRow( + icon: Icons.lock_outline, + title: '数据策略', + trailing: '本地优先', + ), + ], + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () { + if (!loggedIn) { + context.push(AppRoutes.login); + } else { + _toast(context, '云同步入口已打开'); + } + }, + child: Text(loggedIn ? '开启云备份' : '去登录'), + ), + ], + ), + ); + } +} + +class WidgetSettingsPage extends StatelessWidget { + const WidgetSettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionScaffold( + title: '桌面小组件', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _WidgetPreview(), + const SizedBox(height: 14), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: const [ + _SettingLikeRow( + icon: Icons.dashboard_customize_outlined, + title: '组件尺寸', + trailing: '中号', + ), + PrototypeDivider(margin: EdgeInsets.zero), + _SettingLikeRow( + icon: Icons.lock_outline, + title: '金额显示', + trailing: '跟随隐私设置', + ), + ], + ), + ), + const SizedBox(height: 14), + FilledButton( + onPressed: () => _toast(context, '演示:打开系统小组件设置'), + child: const Text('添加到桌面'), + ), + ], + ), + ); + } +} + +class PuritySettingsPage extends ConsumerWidget { + const PuritySettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(profileSettingsControllerProvider); + final groups = [ + ('黄金', ['足金9999', '足金999']), + ('铂金', ['PT950']), + ('白银', ['999银']), + ]; + + return _ActionScaffold( + title: '纯度系数管理', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final group in groups) ...[ + _SectionLabel(group.$1), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (final purity in group.$2) ...[ + _SettingLikeRow( + icon: Icons.tune_outlined, + title: purity, + trailing: (settings.purityPresets[purity] ?? 1) + .toStringAsFixed(4), + onTap: () => _toast(context, '演示:自定义「$purity」纯度系数'), + ), + if (purity != group.$2.last) + const PrototypeDivider(margin: EdgeInsets.zero), + ], + ], + ), + ), + if (group != groups.last) const SizedBox(height: 12), + ], + ], + ), + ); + } +} + +class RecycleDiscountPage extends ConsumerWidget { + const RecycleDiscountPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(profileSettingsControllerProvider); + final notifier = ref.read(profileSettingsControllerProvider.notifier); + final preview = + 483812.0 * (settings.recycleDiscounts[MetalType.gold] ?? 0.97); + + return _ActionScaffold( + title: '回收折扣设置', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PrototypeCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '回收参考预览', + style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)), + ), + const SizedBox(height: 8), + Text( + formatCny(preview), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + height: 1, + ), + ), + const SizedBox(height: 8), + const Text( + '按当前资产总估值和黄金折扣演示,实际以持仓金属分层计算。', + style: TextStyle( + fontSize: 11, + height: 1.5, + color: Color(0xFFB3AFA5), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (final entry in settings.recycleDiscounts.entries) ...[ + _DiscountSliderRow( + metal: entry.key, + value: entry.value, + onChanged: (value) => + notifier.setRecycleDiscount(entry.key, value), + ), + if (entry.key != settings.recycleDiscounts.keys.last) + const PrototypeDivider(margin: EdgeInsets.zero), + ], + ], + ), + ), + ], + ), + ); + } +} + +class PriceColorPage extends ConsumerWidget { + const PriceColorPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cs = ShadTheme.of(context).colorScheme; + final current = ref.watch(profileSettingsControllerProvider).priceColorMode; + final notifier = ref.read(profileSettingsControllerProvider.notifier); + + return _ActionScaffold( + title: '涨跌颜色', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PrototypeCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ColorModeTile( + title: PriceColorMode.redUpGreenDown.label, + upColor: const Color(0xFFC0392B), + downColor: const Color(0xFF2E8B6F), + selected: current == PriceColorMode.redUpGreenDown, + onTap: () => + notifier.setPriceColorMode(PriceColorMode.redUpGreenDown), + ), + const SizedBox(height: 10), + _ColorModeTile( + title: PriceColorMode.greenUpRedDown.label, + upColor: const Color(0xFF2E8B6F), + downColor: const Color(0xFFC0392B), + selected: current == PriceColorMode.greenUpRedDown, + onTap: () => + notifier.setPriceColorMode(PriceColorMode.greenUpRedDown), + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + '当前预览: ${current.label}', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + ], + ), + ); + } +} + +class CurrencyPage extends StatelessWidget { + const CurrencyPage({super.key}); + + @override + Widget build(BuildContext context) { + return const _ActionScaffold( + title: '显示币种', + child: PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _CurrencyRow(label: '人民币', code: '¥ CNY', selected: true), + PrototypeDivider(margin: EdgeInsets.zero), + _CurrencyRow(label: '美元', code: r'$ USD', selected: false), + PrototypeDivider(margin: EdgeInsets.zero), + _CurrencyRow(label: '港币', code: r'HK$ HKD', selected: false), + ], + ), + ), + ); + } +} + +class DisclaimerPage extends StatelessWidget { + const DisclaimerPage({super.key}); + + @override + Widget build(BuildContext context) { + return const _ActionScaffold( + title: '免责声明 · 数据来源', + child: PrototypeCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoParagraph('App 内「材料价值」= 克重 × 纯度系数 × 上海金现货参考价(元/克)。'), + _InfoParagraph('它不是金店零售价(含工费 / 品牌溢价),也不是回收实收价。'), + _InfoParagraph( + '「兑换试算」中各渠道(金店 / 金条 / T+D / 回收)报价均为参考价,恒标来源与时间,以实际门店 / 交易系统为准。', + ), + _InfoParagraph('「回收参考价」= 材料价值 × 可调折扣系数,仅供参考;正式版将尽量对接真实回收报价。'), + _InfoParagraph('本品为资产记录与估值工具,所有信息均不构成投资建议。'), + ], + ), + ), + ); + } +} + +class AboutPage extends StatelessWidget { + const AboutPage({super.key}); + + @override + Widget build(BuildContext context) { + return _ActionScaffold( + title: '意见反馈 · 关于金值', + child: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final version = snapshot.data == null + ? '加载中' + : 'Version ${snapshot.data!.version}+${snapshot.data!.buildNumber}'; + return PrototypeCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '金值 · 贵金属资产管家', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text(version, style: const TextStyle(fontSize: 13)), + const SizedBox(height: 10), + const Text( + '以「我的贵金属资产实时估值」为中心,辅以行情判断与资讯。', + style: TextStyle( + fontSize: 13, + color: Color(0xFF8A8780), + height: 1.5, + ), + ), + const SizedBox(height: 16), + _GoldTextAction( + label: '提交意见反馈', + onTap: () => _toast(context, '演示:打开反馈表单'), + ), + const SizedBox(height: 10), + _GoldTextAction( + label: '检查更新', + onTap: () => _toast(context, '当前已是最新版本'), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _ActionScaffold extends StatelessWidget { + const _ActionScaffold({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.background, + appBar: AppBar( + backgroundColor: cs.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + toolbarHeight: 46, + title: Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + leading: const CircleBackButton(), + ), + body: ListView( + padding: Adaptive.screenPadding(context, top: 8, bottom: 32), + children: [child], + ), + ); + } +} + +class _InviteCodeCard extends StatelessWidget { + const _InviteCodeCard(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1A1A17), Color(0xFF3A2F12)], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Row( + children: [ + _GoldDot(), + SizedBox(width: 7), + Text( + '邀请好友,一起记账各得奖励', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 14), + const Text( + '我的邀请码', + style: TextStyle(fontSize: 12, color: Color(0xFFC9C4B5)), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.30), + width: 0.5, + ), + ), + child: Row( + children: [ + const Expanded( + child: Text( + 'JINZHI-8K3F', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: 2, + ), + ), + ), + GestureDetector( + onTap: () async { + await Clipboard.setData( + const ClipboardData(text: 'JINZHI-8K3F'), + ); + if (!context.mounted) return; + _toast(context, '已复制邀请码'); + }, + child: const Text( + '复制', + style: TextStyle( + color: Color(0xFFB5862A), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SharePreviewCard extends StatelessWidget { + const _SharePreviewCard({required this.owner}); + + final String owner; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1A1A17), Color(0xFF3A2F12)], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Row( + children: [ + _GoldDot(), + SizedBox(width: 7), + Text( + '金值 · 我的贵金属', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 20), + const Text( + '总估值', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Color(0xFFC9C4B5)), + ), + const SizedBox(height: 8), + const Text( + '••••••', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 42, + fontWeight: FontWeight.w700, + height: 1, + ), + ), + const SizedBox(height: 10), + Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFC0392B).withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '•••• · +1.21% 今日', + style: TextStyle( + color: Color(0xFFF0A99E), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 18), + Text( + '来自 $owner · 金额已自动打码', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Color(0xFFC9C4B5)), + ), + ], + ), + ); + } +} + +class _WidgetPreview extends StatelessWidget { + const _WidgetPreview(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: const Color(0xFF1A1A17), + borderRadius: BorderRadius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF7F4EE), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + _GoldDot(), + SizedBox(width: 7), + Text( + '金值', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 14), + const Text( + '¥ 483,812', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + height: 1, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFBEDEA), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '+¥5,821 · +1.21% 今日', + style: TextStyle( + fontSize: 12, + color: Color(0xFFC0392B), + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 14), + const Row( + children: [ + Expanded( + child: _MiniMetal(label: '金', value: '64%'), + ), + SizedBox(width: 8), + Expanded( + child: _MiniMetal(label: '铂', value: '22%'), + ), + SizedBox(width: 8), + Expanded( + child: _MiniMetal(label: '银', value: '14%'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _DiscountSliderRow extends StatelessWidget { + const _DiscountSliderRow({ + required this.metal, + required this.value, + required this.onChanged, + }); + + final MetalType metal; + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + child: Column( + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: metal.color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + metal.label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '${(value * 100).round()}%', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + Slider( + min: 0.7, + max: 1.0, + divisions: 30, + value: value.clamp(0.7, 1.0), + activeColor: const Color(0xFFB5862A), + inactiveColor: const Color(0xFFECEAE3), + onChanged: onChanged, + ), + ], + ), + ); + } +} + +class _ColorModeTile extends StatelessWidget { + const _ColorModeTile({ + required this.title, + required this.upColor, + required this.downColor, + required this.selected, + required this.onTap, + }); + + final String title; + final Color upColor; + final Color downColor; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(13), + decoration: BoxDecoration( + color: selected ? const Color(0xFFF6ECD6) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? const Color(0xFFE3D6B4) : const Color(0xFFECEAE3), + width: 0.5, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + _ColorSwatch(color: upColor, label: '涨'), + const SizedBox(width: 8), + _ColorSwatch(color: downColor, label: '跌'), + const SizedBox(width: 8), + Icon( + selected ? Icons.check_circle : Icons.radio_button_unchecked, + size: 18, + color: selected + ? const Color(0xFFB5862A) + : const Color(0xFFB3AFA5), + ), + ], + ), + ), + ); + } +} + +class _CurrencyRow extends StatelessWidget { + const _CurrencyRow({ + required this.label, + required this.code, + required this.selected, + }); + + final String label; + final String code; + final bool selected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: selected + ? const Color(0xFF1F1D18) + : const Color(0xFFB3AFA5), + ), + ), + ), + Text( + code, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: selected + ? const Color(0xFFB5862A) + : const Color(0xFFB3AFA5), + ), + ), + const SizedBox(width: 8), + Icon( + selected ? Icons.check_circle : Icons.lock_outline, + size: 17, + color: selected ? const Color(0xFFB5862A) : const Color(0xFFB3AFA5), + ), + ], + ), + ); + } +} + +class _SettingLikeRow extends StatelessWidget { + const _SettingLikeRow({ + required this.icon, + required this.title, + required this.trailing, + this.onTap, + }); + + final IconData icon; + final String title; + final String trailing; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Icon(icon, size: 18, color: const Color(0xFFB5862A)), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + Text( + trailing, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF6E6A60), + fontWeight: FontWeight.w600, + ), + ), + if (onTap != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: Color(0xFFB3AFA5)), + ], + ], + ), + ); + if (onTap == null) return content; + return InkWell(onTap: onTap, child: content); + } +} + +class _KeyValueLine extends StatelessWidget { + const _KeyValueLine({ + required this.label, + required this.value, + this.valueColor, + }); + + final String label; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF8A8780)), + ), + ), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: valueColor ?? const Color(0xFF1F1D18), + ), + ), + ], + ), + ); + } +} + +class _GoldTextAction extends StatelessWidget { + const _GoldTextAction({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Text( + '$label ›', + style: const TextStyle( + color: Color(0xFFB5862A), + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _InfoParagraph extends StatelessWidget { + const _InfoParagraph(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text( + text, + style: const TextStyle( + fontSize: 13, + height: 1.65, + color: Color(0xFF6E6A60), + ), + ), + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.label); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 7), + child: Text( + label, + style: const TextStyle(fontSize: 12, color: Color(0xFF8A8780)), + ), + ); + } +} + +class _MiniMetal extends StatelessWidget { + const _MiniMetal({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE6E0D6), width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 11, color: Color(0xFF8A8780)), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} + +class _ColorSwatch extends StatelessWidget { + const _ColorSwatch({required this.color, required this.label}); + + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ); + } +} + +class _GoldDot extends StatelessWidget { + const _GoldDot(); + + @override + Widget build(BuildContext context) { + return Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFB5862A), + shape: BoxShape.circle, + ), + ); + } +} + +void _toast(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/lib/features/profile/presentation/profile_page.dart b/lib/features/profile/presentation/profile_page.dart index 0cceb65..b266cd9 100644 --- a/lib/features/profile/presentation/profile_page.dart +++ b/lib/features/profile/presentation/profile_page.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import '../../../common/config/app_h5_urls.dart'; import '../../../common/router/app_router.dart'; -import '../../../common/widgets/adaptive.dart'; +import '../../../common/widgets/prototype_ui.dart'; import '../../auth/application/auth_controller.dart'; import '../../auth/data/auth_models.dart'; -import '../../profile/application/profile_settings_controller.dart'; +import '../application/profile_settings_controller.dart'; +import '../data/profile_models.dart'; class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); @@ -25,161 +28,416 @@ class ProfilePage extends ConsumerWidget { return SafeArea( top: false, child: ListView( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 24), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 86), 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), - ), - ], - ), + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '我的', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 14), + PrototypeCard( + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: const Color(0xFFF1EFE9), + borderRadius: BorderRadius.circular(999), ), - FilledButton( - onPressed: () => context.push(AppRoutes.settings), - child: const Text('设置'), + child: const Icon( + Icons.person_outline, + color: Color(0xFF9A968D), ), - ], - ), - ), - 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, ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + currentUser?.nickname ?? '未登录', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + currentUser?.phone ?? '登录后可云备份、多端同步', + style: TextStyle( + fontSize: 12, + color: cs.mutedForeground, + ), + ), + ], + ), + ), + if (!loggedIn) + FilledButton( + onPressed: () => context.push(AppRoutes.login), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + minimumSize: Size.zero, + ), + child: Text('登录'), + ), + ], + ), + ), + const SizedBox(height: 12), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _MenuRow( + title: '邀请好友,各得奖励', + subtitle: '需登录', + icon: Icons.card_giftcard_outlined, + backgroundColor: const Color(0xFFF6ECD6), + iconColor: const Color(0xFFB5862A), + onTap: () => _showInviteSheet(context), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _MenuRow( + title: '分享我的贵金属估值卡', + subtitle: '免登录 · 自动打码金额', + icon: Icons.share_outlined, + backgroundColor: const Color(0xFFEAF1FF), + iconColor: const Color(0xFF4F7DD1), + onTap: () => + _showShareCardSheet(context, currentUser?.nickname), + ), + ], + ), + ), + const SizedBox(height: 12), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _SettingRow( + icon: Icons.download_outlined, + title: '本地导出备份', + trailing: '导出', + onTap: () => _showLocalBackupSheet(context), + backgroundColor: const Color(0xFFF3EFE7), + iconColor: const Color(0xFF8A8780), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.cloud_outlined, + title: '云备份 · 多端同步', + trailing: loggedIn ? '已登录' : '登录解锁', + onTap: () => _showCloudBackupSheet(context, loggedIn), + backgroundColor: const Color(0xFFEAF2ED), + iconColor: const Color(0xFF5E8E75), + ), + ], + ), + ), + const SizedBox(height: 12), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _SettingRow( + icon: Icons.grid_view_outlined, + title: '添加桌面小组件', + trailing: '占位', + onTap: () => _showWidgetSheet(context), + backgroundColor: const Color(0xFFF6ECD6), + iconColor: const Color(0xFFB5862A), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.tune_outlined, + title: '纯度系数管理', + trailing: '${settings.purityPresets.length} 项', + onTap: () => _showPuritySheet(context, settings), + backgroundColor: const Color(0xFFF4EAF8), + iconColor: const Color(0xFF8A5CBF), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.percent_outlined, + title: '回收折扣设置', + trailing: + '金 ${(settings.recycleDiscounts.values.first * 100).toStringAsFixed(0)}%', + onTap: () => _showRecycleSheet(context, settings), + backgroundColor: const Color(0xFFF8EFE0), + iconColor: const Color(0xFFB07A32), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.palette_outlined, + title: '涨跌颜色', + trailing: settings.priceColorMode.label, + onTap: () => _showPriceColorSheet(context, ref), + backgroundColor: const Color(0xFFE9F3F1), + iconColor: const Color(0xFF4E8B7B), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.currency_yen_outlined, + title: '显示币种', + trailing: '¥ CNY', + onTap: () => _showCurrencySheet(context), + backgroundColor: const Color(0xFFF1EFE9), + iconColor: const Color(0xFF7F7A73), + ), + ], + ), + ), + const SizedBox(height: 12), + PrototypeCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + _SettingRow( + icon: Icons.settings, + title: '设置', + trailing: null, + onTap: () => context.push(AppRoutes.settings), + backgroundColor: const Color(0xFFF1EFE9), + iconColor: const Color(0xFF7F7A73), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.privacy_tip_outlined, + title: '隐私政策 · 用户协议', + trailing: null, + onTap: () => launchUrlString( + AppH5UrlsHelper.withCacheBuster( + AppH5Urls.userProtocolUrl, + ), + mode: LaunchMode.inAppBrowserView, + ), + backgroundColor: const Color(0xFFF1EFE9), + iconColor: const Color(0xFF7F7A73), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.info_outline, + title: '免责声明 · 数据来源', + trailing: null, + onTap: () => _showDisclaimer(context), + backgroundColor: const Color(0xFFF7F0E2), + iconColor: const Color(0xFFB07A32), + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _SettingRow( + icon: Icons.feedback_outlined, + title: '意见反馈 · 关于金值', + trailing: null, + onTap: () => _showAbout(context), + backgroundColor: const Color(0xFFEAF1FF), + iconColor: const Color(0xFF4F7DD1), + ), + ], + ), + ), + const SizedBox(height: 14), + PrototypeCard( + child: Text( + '风险提示 · 数据来源 · 免责\n金值是贵金属资产记录与估值工具,所有信息不构成投资建议。\n· 行情 / 兑换价格均为参考,标注来源与更新时间,实际以交易系统 / 门店为准。\n· 材料价值 = 克重 × 纯度系数 × 上海金现货,非金店零售价(含工费 / 品牌溢价)、非回收实收价。\n· 资讯为信息整理,非投资建议;历史走势不代表未来。', + style: TextStyle( + color: cs.mutedForeground, + height: 1.7, + fontSize: 12, ), ), + ), + ], + ), + ), + ], + ), + ); + } + + void _showDisclaimer(BuildContext context) { + context.push(AppRoutes.profileDisclaimer); + } + + Future _showInviteSheet(BuildContext context) async { + context.push(AppRoutes.profileInvite); + } + + Future _showShareCardSheet( + BuildContext context, + String? nickname, + ) async { + context.push(AppRoutes.profileShare); + } + + Future _showLocalBackupSheet(BuildContext context) async { + context.push(AppRoutes.profileLocalBackup); + } + + Future _showCloudBackupSheet( + BuildContext context, + bool loggedIn, + ) async { + context.push(AppRoutes.profileCloudBackup); + } + + Future _showWidgetSheet(BuildContext context) async { + context.push(AppRoutes.profileWidget); + } + + Future _showPriceColorSheet(BuildContext context, WidgetRef ref) async { + context.push(AppRoutes.profilePriceColor); + } + + Future _showPuritySheet( + BuildContext context, + ProfileSettings settings, + ) async { + context.push(AppRoutes.profilePurity); + } + + Future _showRecycleSheet( + BuildContext context, + ProfileSettings settings, + ) async { + context.push(AppRoutes.profileRecycle); + } + + Future _showCurrencySheet(BuildContext context) async { + context.push(AppRoutes.profileCurrency); + } + + void _showAbout(BuildContext context) { + context.push(AppRoutes.profileAbout); + } +} + +class _MenuRow extends StatelessWidget { + const _MenuRow({ + required this.title, + required this.subtitle, + required this.icon, + required this.backgroundColor, + required this.iconColor, + required this.onTap, + }); + + final String title; + final String subtitle; + final IconData icon; + final Color backgroundColor; + final Color iconColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), ], ), ), - ), - ], + Icon(Icons.chevron_right, size: 16, color: cs.mutedForeground), + ], + ), ), ); } } class _SettingRow extends StatelessWidget { - const _SettingRow({required this.label, required this.value, this.trailing}); + const _SettingRow({ + required this.icon, + required this.title, + required this.onTap, + required this.backgroundColor, + required this.iconColor, + this.trailing, + }); - final String label; - final String value; - final Widget? trailing; + final IconData icon; + final String title; + final String? trailing; + final VoidCallback onTap; + final Color backgroundColor; + final Color iconColor; @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), + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: iconColor), ), - ), - Text(value, style: TextStyle(color: cs.mutedForeground)), - if (trailing != null) ...[const SizedBox(width: 8), trailing!], - ], + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + if (trailing != null) + Text( + trailing!, + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + const SizedBox(width: 6), + Icon(Icons.chevron_right, size: 16, color: cs.mutedForeground), + ], + ), ), ); } } - -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), - ); - } -}