fix:持仓详情编辑,资讯下拉加载更多

This commit is contained in:
jingyun
2026-06-22 11:36:09 +08:00
parent 08023aff82
commit ab99010306
15 changed files with 1504 additions and 186 deletions
@@ -8,10 +8,17 @@ final newsControllerProvider = StateNotifierProvider<NewsController, NewsState>(
);
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<NewsItem> items;
final DateTime refreshedAt;
final bool hasMore;
final bool loadingMore;
}
class NewsController extends StateNotifier<NewsState> {
@@ -20,15 +27,42 @@ class NewsController extends StateNotifier<NewsState> {
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;
}
}
@@ -8,7 +8,7 @@ final mockNewsRepositoryProvider = Provider<MockNewsRepository>(
);
class MockNewsRepository {
List<NewsItem> loadFeed() {
List<NewsItem> 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);
}
}
+137 -98
View File
@@ -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,
),
),
),
),
],
],
),
),
),
],
],
),
),
);
}