From ab99010306fcb4173d815cec07a374df275fa1f8 Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Mon, 22 Jun 2026 11:36:09 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E6=8C=81=E4=BB=93=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E7=BC=96=E8=BE=91=EF=BC=8C=E8=B5=84=E8=AE=AF=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E5=8A=A0=E8=BD=BD=E6=9B=B4=E5=A4=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/beian/beian_jinzhi.md | 20 + lib/app/app.dart | 42 +- lib/common/router/app_router.dart | 50 +- lib/common/router/tab_shell.dart | 28 +- lib/common/widgets/smart_refresh.dart | 21 + .../assets/presentation/asset_add_page.dart | 585 ++++++++++++++++++ .../assets/presentation/asset_edit_page.dart | 381 ++++++++++++ .../assets/presentation/assets_page.dart | 10 +- .../presentation/holding_detail_page.dart | 141 ++++- .../market/presentation/market_page.dart | 88 ++- .../news/application/news_controller.dart | 38 +- .../news/data/mock_news_repository.dart | 42 +- lib/features/news/presentation/news_page.dart | 235 ++++--- pubspec.lock | 8 + pubspec.yaml | 1 + 15 files changed, 1504 insertions(+), 186 deletions(-) create mode 100644 docs/beian/beian_jinzhi.md create mode 100644 lib/common/widgets/smart_refresh.dart create mode 100644 lib/features/assets/presentation/asset_add_page.dart create mode 100644 lib/features/assets/presentation/asset_edit_page.dart diff --git a/docs/beian/beian_jinzhi.md b/docs/beian/beian_jinzhi.md new file mode 100644 index 0000000..01a9087 --- /dev/null +++ b/docs/beian/beian_jinzhi.md @@ -0,0 +1,20 @@ +应用名称: +金值 + +iOS平台 +Bundle ID + +公钥 + +证书SHA-1指纹 + +Android +包名: +com.jinzhi.butler +MD5: +bf875d2980f09f3acca3614b10aa0463 +公钥(16进制) +a61ce1268140ae32cd7aeb43dfb2324a1bb04aba5c7cd5879e6f3b3e052f926ef80b9eccfc53b057a6a01d7dadf573ac19bf4bcfa9c4296fae20a0e1f4f598685321e4b3767bbc3a98d6afd056a522194528c3df1fed3872c697c49bc40fdc963071018ec644233e19e78bae9d3b5e5925e925322c5211f12fb079aa19464abd44bb782d81a30cc55c1b090f691d18aeb73beb26a710f45bb31a605ccf6519fe17e03130666e787bd2de0ceb8fd4bb66f2fd706af34db7840a504da090bb37f50e06153a84c3215ab1e6cbbb225b681baa84d90f97f1e245664d3fb6a1072506f4ba0548a4c28d84b239ed8500b0eb914bd3585565658e964317b48c0d84791b + +签名证书SHA-1指纹 +70 A8 9B 61 BE 5C 51 0A B8 42 78 2A FE 66 3A 4D 55 B9 10 4F diff --git a/lib/app/app.dart b/lib/app/app.dart index 8918f71..86fe8fc 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../common/router/app_router.dart'; import '../common/theme/jinzhi_theme.dart'; import '../common/theme/spacing.dart'; import '../common/theme/theme_controller.dart'; +import '../common/widgets/smart_refresh.dart'; class JinzhiApp extends ConsumerWidget { const JinzhiApp({super.key}); @@ -15,22 +17,34 @@ class JinzhiApp extends ConsumerWidget { final router = ref.watch(routerProvider); final mode = ref.watch(themeModeControllerProvider); final textTheme = ShadTextTheme(); - return ShadApp.router( - title: '金值', - themeMode: mode, - theme: ShadThemeData( - brightness: Brightness.light, - colorScheme: jinzhiLightScheme, - textTheme: textTheme, - radius: BorderRadius.circular(Spacing.radiusBase), + return RefreshConfiguration( + springDescription: const SpringDescription( + mass: 1, + stiffness: 364.718677686, + damping: 35.2, ), - darkTheme: ShadThemeData( - brightness: Brightness.dark, - colorScheme: jinzhiDarkScheme, - textTheme: textTheme, - radius: BorderRadius.circular(Spacing.radiusBase), + headerBuilder: () => const MaterialClassicHeader(), + footerBuilder: () => jinzhiClassicFooter, + hideFooterWhenNotFull: true, + headerTriggerDistance: 60, + maxOverScrollExtent: 100, + child: ShadApp.router( + title: '金值', + themeMode: mode, + theme: ShadThemeData( + brightness: Brightness.light, + colorScheme: jinzhiLightScheme, + textTheme: textTheme, + radius: BorderRadius.circular(Spacing.radiusBase), + ), + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: jinzhiDarkScheme, + textTheme: textTheme, + radius: BorderRadius.circular(Spacing.radiusBase), + ), + routerConfig: router, ), - routerConfig: router, ); } } diff --git a/lib/common/router/app_router.dart b/lib/common/router/app_router.dart index ab67510..cd9d742 100644 --- a/lib/common/router/app_router.dart +++ b/lib/common/router/app_router.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../features/assets/presentation/asset_add_page.dart'; +import '../../features/assets/presentation/asset_edit_page.dart'; import '../../features/assets/presentation/assets_page.dart'; import '../../features/assets/presentation/holding_detail_page.dart'; import '../../features/auth/presentation/delete_account_page.dart'; @@ -24,6 +26,8 @@ import 'tab_shell.dart'; abstract final class AppRoutes { static const assets = '/assets'; + static const assetAdd = '/assets/add'; + static const assetEdit = '/assets/edit'; static const market = '/market'; static const marketKline = '/market/kline'; static const news = '/news'; @@ -93,8 +97,19 @@ final routerProvider = Provider((ref) { ), GoRoute( path: AppRoutes.assetDetail, - builder: (ctx, s) => - HoldingDetailPage(id: s.uri.queryParameters['id']), + builder: (ctx, s) => HoldingDetailPage(id: s.uri.queryParameters['id']), + ), + GoRoute( + path: AppRoutes.assetAdd, + pageBuilder: (ctx, s) => + _rightToLeftPage(state: s, child: const AssetAddPage()), + ), + GoRoute( + path: AppRoutes.assetEdit, + pageBuilder: (ctx, s) => _rightToLeftPage( + state: s, + child: AssetEditPage(id: s.uri.queryParameters['id'] ?? ''), + ), ), GoRoute( path: AppRoutes.marketKline, @@ -223,10 +238,33 @@ CustomTransitionPage _bottomUpPage({ begin: const Offset(0, 0.08), end: Offset.zero, ).animate(curved), - child: FadeTransition( - opacity: curved, - child: pageChild, - ), + child: FadeTransition(opacity: curved, child: pageChild), + ); + }, + ); +} + +CustomTransitionPage _rightToLeftPage({ + 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(1, 0), + 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 bd463a1..7671460 100644 --- a/lib/common/router/tab_shell.dart +++ b/lib/common/router/tab_shell.dart @@ -161,20 +161,20 @@ class _TabButton extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (selected) - Container( - width: 22, - height: 2.5, - margin: const EdgeInsets.only(bottom: 6), - decoration: BoxDecoration( - color: cs.primary, - borderRadius: const BorderRadius.vertical( - bottom: Radius.circular(3), - ), - ), - ) - else - const SizedBox(height: 8.5), + // if (selected) + // Container( + // width: 22, + // height: 2.5, + // margin: const EdgeInsets.only(bottom: 6), + // decoration: BoxDecoration( + // color: cs.primary, + // borderRadius: const BorderRadius.vertical( + // bottom: Radius.circular(3), + // ), + // ), + // ) + // else + // const SizedBox(height: 8.5), Icon(icon, size: 22, color: color), const SizedBox(height: 4), Text( diff --git a/lib/common/widgets/smart_refresh.dart b/lib/common/widgets/smart_refresh.dart new file mode 100644 index 0000000..5ab9773 --- /dev/null +++ b/lib/common/widgets/smart_refresh.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +const ClassicFooter jinzhiClassicFooter = ClassicFooter( + height: 40, + idleIcon: const SizedBox.shrink(), + canLoadingIcon: const SizedBox.shrink(), + noMoreIcon: const SizedBox.shrink(), + failedIcon: const SizedBox.shrink(), + loadingIcon: const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + loadingText: '', + idleText: '', + canLoadingText: '', + noDataText: '', + failedText: '', + textStyle: const TextStyle(fontSize: 12, color: Colors.grey), +); diff --git a/lib/features/assets/presentation/asset_add_page.dart b/lib/features/assets/presentation/asset_add_page.dart new file mode 100644 index 0000000..a60a4f7 --- /dev/null +++ b/lib/features/assets/presentation/asset_add_page.dart @@ -0,0 +1,585 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../common/domain/metal_type.dart'; +import '../../../common/domain/money.dart'; +import '../../../common/widgets/prototype_ui.dart'; +import '../../market/application/market_controller.dart'; +import '../application/asset_portfolio_controller.dart'; +import '../data/asset_models.dart'; + +class AssetAddPage extends ConsumerStatefulWidget { + const AssetAddPage({super.key}); + + @override + ConsumerState createState() => _AssetAddPageState(); +} + +class _AssetAddPageState extends ConsumerState { + 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 quote = market.quotes.firstWhere((item) => item.metal == _metal); + final basePrice = quote.spotPrice; + final value = _gram * _factorFor(_purity) * basePrice; + + return Scaffold( + backgroundColor: const Color(0xFFFBFAF7), + appBar: AppBar( + backgroundColor: const Color(0xFFFBFAF7), + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + title: const Text( + '添加资产', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + leading: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.chevron_left, size: 20), + ), + ), + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), + children: [ + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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), + SizedBox( + width: double.infinity, + 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(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已添加 · 总估值已重算')), + ); + }, + 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/asset_edit_page.dart b/lib/features/assets/presentation/asset_edit_page.dart new file mode 100644 index 0000000..3e804f6 --- /dev/null +++ b/lib/features/assets/presentation/asset_edit_page.dart @@ -0,0 +1,381 @@ +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 '../../market/application/market_controller.dart'; +import '../application/asset_portfolio_controller.dart'; +import '../data/asset_models.dart'; + +class AssetEditPage extends ConsumerStatefulWidget { + const AssetEditPage({super.key, required this.id}); + + final String id; + + @override + ConsumerState createState() => _AssetEditPageState(); +} + +class _AssetEditPageState extends ConsumerState { + late GoldAssetHolding _holding; + late final TextEditingController _weightCtrl; + late final TextEditingController _costCtrl; + late final TextEditingController _dateCtrl; + late final TextEditingController _channelCtrl; + double _gram = 0; + + @override + void initState() { + super.initState(); + _holding = ref + .read(assetPortfolioControllerProvider) + .holdings + .map((item) => item.holding) + .firstWhere((item) => item.id == widget.id); + _gram = _holding.weightGram; + _weightCtrl = TextEditingController( + text: _holding.weightGram.toStringAsFixed( + _holding.weightGram % 1 == 0 ? 0 : 1, + ), + ); + _costCtrl = TextEditingController( + text: _holding.costAmount?.toStringAsFixed(0) ?? '', + ); + _dateCtrl = TextEditingController( + text: _holding.purchaseDate == null + ? '' + : '${_holding.purchaseDate!.year}-${_holding.purchaseDate!.month.toString().padLeft(2, '0')}-${_holding.purchaseDate!.day.toString().padLeft(2, '0')}', + ); + _channelCtrl = TextEditingController(text: _holding.channel ?? ''); + } + + @override + void dispose() { + _weightCtrl.dispose(); + _costCtrl.dispose(); + _dateCtrl.dispose(); + _channelCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final summary = ref.watch(assetPortfolioControllerProvider); + final valuation = summary.holdings.firstWhere( + (item) => item.holding.id == widget.id, + ); + _holding = valuation.holding; + final cs = ShadTheme.of(context).colorScheme; + final market = ref.watch(marketControllerProvider); + final quote = market.quotes.firstWhere( + (item) => item.metal == _holding.metal, + ); + final value = _gram * _holding.purity * quote.spotPrice; + + return Scaffold( + backgroundColor: const Color(0xFFFBFAF7), + appBar: AppBar( + backgroundColor: const Color(0xFFFBFAF7), + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + title: Text( + '编辑·${_holding.name}', + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + leading: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.chevron_left, size: 20), + ), + ), + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), + children: [ + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PrototypeCard( + child: Row( + children: [ + Text( + '金属 / 纯度', + style: TextStyle( + fontSize: 13, + color: cs.mutedForeground, + ), + ), + const Spacer(), + Text( + '${_holding.metal.label} · ${_holding.purityLabel}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const Text( + '克重(克)', + style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)), + ), + const SizedBox(height: 9), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + 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), + ), + ), + ), + ], + ), + ), + Slider( + min: 1, + max: 200, + value: _gram.clamp(1, 200), + onChanged: (value) => setState(() { + _gram = value; + _weightCtrl.text = value.toStringAsFixed( + value % 1 == 0 ? 0 : 1, + ); + }), + 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: Row( + children: [ + const Expanded( + child: Text( + '实时材料价值', + style: TextStyle( + fontSize: 13, + color: Color(0xFF7A5B12), + ), + ), + ), + Text( + formatCny(value), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(height: 14), + _FieldRow( + label: '买入成本(含工费)', + child: TextField( + controller: _costCtrl, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + textAlign: TextAlign.right, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: '选填', + ), + ), + ), + const SizedBox(height: 10), + _FieldRow( + label: '购买日期', + child: TextField( + controller: _dateCtrl, + textAlign: TextAlign.right, + decoration: const InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: 'YYYY-MM-DD', + ), + ), + ), + 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: 16), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: () { + final grams = double.tryParse( + _weightCtrl.text.trim(), + ); + final cost = double.tryParse(_costCtrl.text.trim()); + final parsedDate = DateTime.tryParse( + _dateCtrl.text.trim(), + ); + ref + .read(assetPortfolioControllerProvider.notifier) + .updateHolding( + GoldAssetHolding( + id: _holding.id, + name: _holding.name, + metal: _holding.metal, + category: _holding.category, + purity: _holding.purity, + purityLabel: _holding.purityLabel, + weightGram: grams ?? _holding.weightGram, + costAmount: cost, + purchaseDate: parsedDate, + channel: _channelCtrl.text.trim().isEmpty + ? null + : _channelCtrl.text.trim(), + createdAt: _holding.createdAt, + updatedAt: DateTime.now(), + status: _holding.status, + ), + ); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已保存修改 · 总值已重算')), + ); + }, + child: const Text('保存修改'), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + '修改后会即时重算资产总值', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +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( + children: [ + Text( + label, + style: const TextStyle(fontSize: 13, color: Color(0xFF8A8780)), + ), + const SizedBox(width: 10), + Expanded(child: child), + ], + ), + ); + } +} + +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), + ), + ), + ), + ); + } +} diff --git a/lib/features/assets/presentation/assets_page.dart b/lib/features/assets/presentation/assets_page.dart index f4c42d6..ae86a4f 100644 --- a/lib/features/assets/presentation/assets_page.dart +++ b/lib/features/assets/presentation/assets_page.dart @@ -302,15 +302,7 @@ class AssetsPage extends ConsumerWidget { } 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(), - ); + context.push(AppRoutes.assetAdd); } String _signedCny(double value) { diff --git a/lib/features/assets/presentation/holding_detail_page.dart b/lib/features/assets/presentation/holding_detail_page.dart index 22c6ad6..5b0d80f 100644 --- a/lib/features/assets/presentation/holding_detail_page.dart +++ b/lib/features/assets/presentation/holding_detail_page.dart @@ -50,6 +50,13 @@ class HoldingDetailPage extends ConsumerWidget { holding.name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), ), + actions: [ + IconButton( + onPressed: () => _showActionsSheet(context, ref, holding), + icon: const Icon(Icons.more_horiz, size: 22), + color: const Color(0xFF8A8780), + ), + ], ), body: SafeArea( child: ListView( @@ -232,13 +239,22 @@ class HoldingDetailPage extends ConsumerWidget { ], ), ), - const SizedBox(height: 14), + const SizedBox(height: 12), Row( children: [ Expanded( child: OutlinedButton( - onPressed: () => - _showEditSheet(context, ref, holding), + onPressed: () => context.push( + '${AppRoutes.assetEdit}?id=${holding.id}', + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFE3D6B4)), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + foregroundColor: const Color(0xFF9A7A2A), + ), child: const Text('编辑'), ), ), @@ -246,6 +262,14 @@ class HoldingDetailPage extends ConsumerWidget { Expanded( child: OutlinedButton( onPressed: () => _markSold(context, ref, holding.id), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFE6E1D8)), + foregroundColor: const Color(0xFF8A8780), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), child: const Text('标记卖出 / 转赠'), ), ), @@ -260,29 +284,73 @@ class HoldingDetailPage extends ConsumerWidget { ); } - void _showEditSheet( + void _showActionsSheet( BuildContext context, WidgetRef ref, GoldAssetHolding holding, ) { showModalBottomSheet( context: context, - isScrollControlled: true, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), - builder: (ctx) => _EditHoldingSheet( - holding: holding, - onSave: (updated) { - ref - .read(assetPortfolioControllerProvider.notifier) - .updateHolding(updated); - Navigator.of(ctx).pop(); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('已保存修改 · 总值已重算'))); - }, + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 38, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: const Color(0xFFE3DFD4), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + _ActionMenuTile( + icon: Icons.edit_outlined, + label: '编辑持仓', + onTap: () { + Navigator.of(ctx).pop(); + context.push('${AppRoutes.assetEdit}?id=${holding.id}'); + }, + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _ActionMenuTile( + icon: Icons.share_outlined, + label: '分享这张的估值卡', + onTap: () { + Navigator.of(ctx).pop(); + context.push(AppRoutes.profileShare); + }, + ), + const PrototypeDivider(margin: EdgeInsets.zero), + _ActionMenuTile( + icon: Icons.sell_outlined, + label: '标记卖出 / 转赠', + labelColor: const Color(0xFFC0392B), + iconColor: const Color(0xFFC0392B), + onTap: () { + Navigator.of(ctx).pop(); + _markSold(context, ref, holding.id); + }, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('取消'), + ), + ), + ], + ), + ), ), ); } @@ -296,6 +364,47 @@ class HoldingDetailPage extends ConsumerWidget { } } +class _ActionMenuTile extends StatelessWidget { + const _ActionMenuTile({ + required this.icon, + required this.label, + required this.onTap, + this.iconColor, + this.labelColor, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final Color? iconColor; + final Color? labelColor; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 16), + child: Row( + children: [ + Icon(icon, size: 20, color: iconColor ?? const Color(0xFF8A8780)), + const SizedBox(width: 12), + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: labelColor ?? cs.foreground, + ), + ), + ], + ), + ), + ); + } +} + class _InfoRow extends StatelessWidget { const _InfoRow({required this.label, required this.value, this.valueColor}); diff --git a/lib/features/market/presentation/market_page.dart b/lib/features/market/presentation/market_page.dart index 6b8bf81..944927e 100644 --- a/lib/features/market/presentation/market_page.dart +++ b/lib/features/market/presentation/market_page.dart @@ -93,32 +93,30 @@ class MarketPage extends HookConsumerWidget { ], ), const SizedBox(height: 14), - SizedBox( - height: 32, - child: ListView.separated( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - itemCount: MetalType.values.length, - separatorBuilder: (context, index) => + Row( + children: [ + for (var i = 0; i < MetalType.values.length; i++) ...[ + Expanded( + child: _MetalTab( + label: MetalType.values[i].label, + selected: MetalType.values[i] == market.selectedMetal, + onTap: () { + final metal = MetalType.values[i]; + ref + .read(marketControllerProvider.notifier) + .selectMetal(metal); + ref + .read( + exchangeCalculatorControllerProvider.notifier, + ) + .selectMetal(metal); + }, + ), + ), + if (i != MetalType.values.length - 1) 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: 10), PrototypeCard( @@ -501,6 +499,46 @@ class _PeriodPill extends StatelessWidget { } } +class _MetalTab extends StatelessWidget { + const _MetalTab({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? const Color(0xFFF6ECD6) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? const Color(0xFFE3D6B4) : const Color(0xFFE6E1D8), + width: 0.5, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? const Color(0xFF7A5B12) : const Color(0xFF6E6A60), + ), + ), + ), + ); + } +} + class _ChangeChip extends StatelessWidget { const _ChangeChip({ required this.amount, diff --git a/lib/features/news/application/news_controller.dart b/lib/features/news/application/news_controller.dart index 3b655f5..3132c7c 100644 --- a/lib/features/news/application/news_controller.dart +++ b/lib/features/news/application/news_controller.dart @@ -8,10 +8,17 @@ final newsControllerProvider = StateNotifierProvider( ); class NewsState { - const NewsState({required this.items, required this.refreshedAt}); + const NewsState({ + required this.items, + required this.refreshedAt, + required this.hasMore, + required this.loadingMore, + }); final List items; final DateTime refreshedAt; + final bool hasMore; + final bool loadingMore; } class NewsController extends StateNotifier { @@ -20,15 +27,42 @@ class NewsController extends StateNotifier { NewsState( items: _repository.loadFeed(), refreshedAt: DateTime(2026, 6, 18, 16, 11), + hasMore: true, + loadingMore: false, ), ); final MockNewsRepository _repository; + static const _pageSize = 3; void refresh() { + final items = _repository.loadFeed(limit: _pageSize); state = NewsState( - items: _repository.loadFeed(), + items: items, refreshedAt: DateTime.now(), + hasMore: items.length == _pageSize, + loadingMore: false, ); } + + bool loadMore() { + if (state.loadingMore || !state.hasMore) return state.hasMore; + state = NewsState( + items: state.items, + refreshedAt: state.refreshedAt, + hasMore: state.hasMore, + loadingMore: true, + ); + final next = _repository.loadFeed( + offset: state.items.length, + limit: _pageSize, + ); + state = NewsState( + items: [...state.items, ...next], + refreshedAt: state.refreshedAt, + hasMore: next.length == _pageSize, + loadingMore: false, + ); + return state.hasMore; + } } diff --git a/lib/features/news/data/mock_news_repository.dart b/lib/features/news/data/mock_news_repository.dart index 0ca2417..c7e9f11 100644 --- a/lib/features/news/data/mock_news_repository.dart +++ b/lib/features/news/data/mock_news_repository.dart @@ -8,7 +8,7 @@ final mockNewsRepositoryProvider = Provider( ); class MockNewsRepository { - List loadFeed() { + List loadFeed({int offset = 0, int limit = 3}) { final base = DateTime(2026, 6, 18, 16, 11); final items = [ NewsItem( @@ -23,6 +23,18 @@ class MockNewsRepository { '对持有者而言,材料价值更适合用于日常估值;对购买者而言,需要额外关注工费、品牌溢价与回收折扣。', ], ), + NewsItem( + id: 'news_gold_002', + metal: MetalType.gold, + title: '金饰零售端促销频繁,注意工费与克重拆分', + source: '门店观察', + publishedAt: base.subtract(const Duration(hours: 3, minutes: 12)), + summary: '促销券常常只影响零售支付价,材料价值并不会同步下降。', + body: const [ + '金饰零售端的活动更偏向工费减免、赠品或满减,对材料价值本身没有直接影响。', + '记录资产时建议保留克重、纯度、成本和购买渠道,便于后续估值对照。', + ], + ), NewsItem( id: 'news_silver_001', metal: MetalType.silver, @@ -35,6 +47,18 @@ class MockNewsRepository { '银饰通常包含较高加工与零售成本,材料价值占比可能低于用户直觉。', ], ), + NewsItem( + id: 'news_silver_002', + metal: MetalType.silver, + title: '白银工艺件价格波动更敏感,建议看实时材料价', + source: '市场简报', + publishedAt: base.subtract(const Duration(hours: 4, minutes: 8)), + summary: '白银饰品受工费和零售渠道影响更大,短线估值容易偏离材料价值。', + body: const [ + '白银工艺件的零售价常常受加工复杂度影响,和材料价值的差异更明显。', + '如果只看零售价很容易高估持仓,记录时建议同时看克重和纯度。', + ], + ), NewsItem( id: 'news_platinum_001', metal: MetalType.platinum, @@ -47,7 +71,21 @@ class MockNewsRepository { '记录持仓时建议保留纯度、克重、购买渠道和成本信息,便于后续估值与盈亏回看。', ], ), + NewsItem( + id: 'news_platinum_002', + metal: MetalType.platinum, + title: '铂金回收询价分层明显,渠道差异值得记录', + source: '金属观察', + publishedAt: base.subtract(const Duration(hours: 5, minutes: 16)), + summary: '不同渠道对 PT950 的检测和报价方式并不一样,保存渠道信息很重要。', + body: const [ + '铂金回收通常会区分门店、品牌和回收市场,检测流程与报价节奏都有差异。', + '把渠道信息留在资产记录里,后面回看更容易对上真实回收路径。', + ], + ), ]; - return [...items]..sort((a, b) => b.publishedAt.compareTo(a.publishedAt)); + final sorted = [...items] + ..sort((a, b) => b.publishedAt.compareTo(a.publishedAt)); + return sorted.skip(offset).take(limit).toList(growable: false); } } diff --git a/lib/features/news/presentation/news_page.dart b/lib/features/news/presentation/news_page.dart index 9c55535..a91020d 100644 --- a/lib/features/news/presentation/news_page.dart +++ b/lib/features/news/presentation/news_page.dart @@ -1,125 +1,164 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../common/router/app_router.dart'; import '../../../common/widgets/prototype_ui.dart'; import '../application/news_controller.dart'; -class NewsPage extends ConsumerWidget { +class NewsPage extends HookConsumerWidget { const NewsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final news = ref.watch(newsControllerProvider); + final notifier = ref.read(newsControllerProvider.notifier); final cs = ShadTheme.of(context).colorScheme; + final controller = useMemoized( + () => RefreshController(initialRefresh: false), + ); + + useEffect(() { + return controller.dispose; + }, [controller]); + return SafeArea( top: false, - child: ListView( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 86), - children: [ - 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, + child: SmartRefresher( + controller: controller, + enablePullDown: true, + enablePullUp: true, + header: const MaterialClassicHeader(), + onRefresh: () async { + notifier.refresh(); + controller.refreshCompleted(); + }, + onLoading: () async { + if (!news.hasMore || news.loadingMore) { + controller.loadNoData(); + return; + } + final hasMore = notifier.loadMore(); + if (hasMore) { + controller.loadComplete(); + } else { + controller.loadNoData(); + } + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 86), + children: [ + PrototypeFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '资讯', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + 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, + ), + ), + ], + ), ), ), ], - ), - 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, - ), - ), - ], + if (news.loadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + if (!news.hasMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Text( + '没有更多资讯了', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: cs.mutedForeground, + ), ), ), - ), ], - ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 2d7edfb..df3df76 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -963,6 +963,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + pull_to_refresh: + dependency: "direct main" + description: + name: pull_to_refresh + sha256: bbadd5a931837b57739cf08736bea63167e284e71fb23b218c8c9a6e042aad12 + url: "https://pub.dev" + source: hosted + version: "2.0.0" retrofit: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 823d8ca..8907a20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: shadcn_ui: ^0.53.6 google_fonts: ^6.2.1 flutter_remix: ^0.0.3 + pull_to_refresh: ^2.0.0 shimmer: ^3.0.0 flutter_markdown_plus: ^1.0.7 video_player: ^2.9.2 # 启动页视频背景